Skip to main content
Zunder supports FHIR Bundles as multi-request envelopes when you POST a Bundle to the server base endpoint (POST /fhir).
  • Bundle.type = batch: entries are independent (partial success is expected)
  • Bundle.type = transaction: atomic (all-or-nothing), with intra-transaction reference rewriting
This page documents Zunder-specific behavior, especially the transaction mechanics.

Quick Comparison

Aspectbatchtransaction
AtomicNoYes
Inter-entry references via fullUrlRejected (non-conformant)Supported (rewritten)
Conditional references (Patient?identifier=...)Supported (must resolve to exactly one match)Supported (must resolve to exactly one match)
Overall HTTP status200 (with per-entry statuses)200 on success; non-2xx on any failure
Processing orderDELETE → POST → PUT/PATCH → GET/HEADDELETE → POST → PUT/PATCH → (finalize refs) → GET/HEAD
Conditional ops (If-None-Exist / criteria URLs)SupportedSupported (resolved inside the DB transaction)

Endpoint and Request Shape

Send a Bundle to the base endpoint:
curl -X POST "http://localhost:8080/fhir" \
  -H "Content-Type: application/fhir+json" \
  -d '{ "resourceType": "Bundle", "type": "transaction", "entry": [] }'
The handler accepts:
  • type: "batch"
  • type: "transaction"
  • type: "history" (replication; not covered here)
In Bundle.entry.request.url, Zunder accepts both relative and absolute URLs (it strips scheme/host before parsing). Example: Patient/123 and https://example.com/fhir/Patient/123 are treated the same.

Response Detail Level (Prefer)

Zunder supports these response styles for both batch and transaction:
  • Prefer: return=minimal → status/location/etag only
  • Prefer: return=representation → include the resource bodies in the response bundle
  • Prefer: return=OperationOutcome → include an OperationOutcome in entry.response.outcome

Batch Bundles (type=batch)

Batch entries are processed independently (FHIR “batch” semantics).

Independence rules Zunder enforces

Zunder pre-validates and flags non-conformant patterns per entry:
  • No reference resolution between entries: if a resource contains Reference.reference equal to a fullUrl of a POST entry in the same batch, that entry is rejected.
  • No change-interdependencies: multiple PUT/PATCH/DELETE entries targeting the same {type}/{id} are rejected.
If you need entries to reference each other, use a transaction bundle.

Error shape

Batch requests typically return HTTP 200 with a Bundle.type = batch-response. Errors are represented per entry via entry.response.status and an OperationOutcome.

Transaction Bundles (type=transaction)

Transactions are atomic: Zunder runs the entire bundle inside a single database transaction.
  • If every entry succeeds → commit and return Bundle.type = transaction-response (HTTP 200).
  • If any entry fails → rollback and return a normal error response (non-2xx) and no changes are persisted.
Error messages include entry context like Transaction entry {index}: ... to help you pinpoint which entry triggered the rollback.

Processing order (important)

Zunder processes entries in this order (regardless of original order):
  1. DELETE
  2. POST (create)
  3. PUT / PATCH (update)
  4. Finalize “resolve-as-version-specific” references (see below)
  5. GET / HEAD (read)
This order matches the implementation in server/src/services/transaction.rs.

Constraints Zunder enforces

  • No duplicate fullUrl values across entries.
  • No identity overlaps for change interactions: you can’t have multiple DELETE/PUT/PATCH entries targeting the same {type}/{id} inside one transaction (to avoid order-dependent outcomes).
  • GET/HEAD inside a transaction is instance-read only; if the URL has no id (looks like a type-level search), Zunder returns an empty searchset bundle entry.

fullUrl mapping and intra-transaction reference rewriting

Transactions support the common FHIR pattern:
  • Create a resource in one entry (POST)
  • Reference it from another entry using the first entry’s fullUrl
Zunder implements this by building a mapping and rewriting resources before writing them:
  1. For non-POST entries, if entry.fullUrl exists and the request URL is an identity ({type}/{id}), Zunder maps fullUrl → "{type}/{id}".
  2. For POST entries, Zunder pre-reserves UUID ids and maps fullUrl → "{type}/{uuid}" before processing entries, so later entries can safely reference the would-be created resources.
  3. Before POST/PUT/PATCH, Zunder rewrites string values inside the resource JSON:
    • Exact fullUrl matches are replaced (fragment-aware: urn:...#frag works).
    • Generic string replacement is also applied (e.g., narrative strings), except under canonical element paths.
Canonical fields are intentionally excluded from rewriting so canonical URLs don’t accidentally get rewritten when they happen to contain a fullUrl substring.

Conditional references inside resources

Zunder supports conditional references (FHIR “search URIs”) inside request resources: Example:
<subject>
  <reference value="Patient?identifier=http://example.org/fhir/mrn|12345"/>
</subject>
Rules (FHIR-style):
  • Zunder searches the target type (Patient in the example) using the query string parameters.
  • If there are 0 matches or >1 match, the interaction fails:
    • transaction: the transaction fails (and everything rolls back)
    • batch: the entry fails with 412 (other entries are unaffected)
  • If there is exactly 1 match, Zunder replaces the search URI with a normal reference (Patient/{id}).
See also: CRUD Operations → Conditional References.

Conditional operations inside a transaction

Zunder supports the common conditional patterns and resolves them within the transaction, meaning: searches see the effects of earlier writes in the same bundle. Supported patterns:
  • Conditional create: POST + entry.request.ifNoneExist
  • Conditional update: PUT {type}?{criteria} (optionally with If-None-Match)
  • Conditional patch: PATCH {type}?{criteria}
  • Conditional delete: DELETE {type}?{criteria}

Concurrency control (If-Match, If-None-Match)

Zunder enforces version checks where applicable:
  • If-Match: W/"{versionId}" is validated for PUT, PATCH, and DELETE.
  • Conditional PUT supports If-None-Match: * semantics (via the conditional update resolver).

PATCH in transactions (JSON Patch via Binary)

For transaction PATCH, Zunder expects the entry’s resource to be a Binary containing a JSON Patch document:
  • Binary.contentType = "application/json-patch+json"
  • Binary.data = base64-encoded JSON Patch bytes
Security/safety note: after applying the patch, Zunder removes resource.text (narrative) because it may no longer match the updated data. Example entry shape (data omitted for brevity):
{
  "request": { "method": "PATCH", "url": "Patient/123" },
  "resource": {
    "resourceType": "Binary",
    "contentType": "application/json-patch+json",
    "data": "BASE64_ENCODED_JSON_PATCH_BYTES"
  }
}

Version-specific references (resolve-as-version-specific)

Zunder implements the FHIR extension: http://hl7.org/fhir/StructureDefinition/resolve-as-version-specific If a Reference element has this extension and contains a versionless local reference ({type}/{id}), Zunder will (after all writes) rewrite it to a version-specific reference: {type}/{id}/_history/{versionId} …but only when the referenced target was written in the same transaction. The extension is removed as part of resolution.

Examples

Transaction: create + reference via fullUrl

{
  "resourceType": "Bundle",
  "type": "transaction",
  "entry": [
    {
      "fullUrl": "urn:uuid:patient-1",
      "request": { "method": "POST", "url": "Patient" },
      "resource": { "resourceType": "Patient", "name": [{ "family": "Doe" }] }
    },
    {
      "request": { "method": "POST", "url": "Observation" },
      "resource": {
        "resourceType": "Observation",
        "status": "final",
        "subject": { "reference": "urn:uuid:patient-1" }
      }
    }
  ]
}
Zunder will rewrite subject.reference to Patient/{uuid} before inserting the Observation.

Transaction: conditional create (ifNoneExist)

{
  "resourceType": "Bundle",
  "type": "transaction",
  "entry": [
    {
      "fullUrl": "urn:uuid:patient-1",
      "request": {
        "method": "POST",
        "url": "Patient",
        "ifNoneExist": "identifier=http://acme.example/mrn|123"
      },
      "resource": {
        "resourceType": "Patient",
        "identifier": [{ "system": "http://acme.example/mrn", "value": "123" }]
      }
    }
  ]
}
If a match exists, the entry returns 200 OK and fullUrl mapping points at the existing resource.