eehrxf 0.4.0

The European interoperability software component of an EHR system under Regulation (EU) 2025/327: the format-neutral dataset model of the priority categories read from the Xt-EHR EHDS logical models, and the European electronic health record exchange format in FHIR R4, mapped from openEHR by FHIRconnect
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
// SPDX-FileCopyrightText: Cadasto B.V.
// SPDX-License-Identifier: BUSL-1.1

//! Receiving a document in the exchange format (feature `fhir-r4`).
//!
//! Annex II 2.2 and 2.3 of Regulation (EU) 2025/327 ask an EHR system that
//! stores, intermediates or gives access to personal electronic health data
//! to "be able to receive" them in the European exchange format, by means of
//! this component. A [`ReceivedDocument`] is that receipt: a FHIR R4 document
//! `Bundle`, decoded strictly against the R4 resource definitions, held to
//! the R4 document rules, with its subject resolved to the `Patient` entry
//! and its original text kept byte for byte.
//!
//! - [`category`]: the priority category the document's `Composition` names,
//!   and the profiles a document of it is checked against;
//! - [`conform`]: the document checked against the profiles of its category,
//!   read from the vendored package;
//! - [`validation`]: the document held to every constraint of its profiles
//!   and of the profiles its entries claim, by FerroBRIDGE's validator;
//! - `openehr` (feature `openehr`): the document mapped into one openEHR
//!   composition by FHIRconnect, the original kept beside the mapped content.
//!
//! Nothing here reaches a CDR. Which member stores the composition, and how
//! it is reached, is the system's to decide.
//!
//! # Examples
//!
//! ```no_run
//! use eehrxf::receive::ReceivedDocument;
//!
//! let text = std::fs::read_to_string("document.json")?;
//! let document = ReceivedDocument::read(&text)?;
//! assert_eq!(document.original(), text);
//! # Ok::<(), Box<dyn std::error::Error>>(())
//! ```

#[cfg(feature = "openehr")]
mod accounted;
pub mod category;
pub mod conform;
pub mod displays;
mod entries;
#[cfg(feature = "openehr")]
pub mod openehr;
pub mod reference;
mod references;
pub(crate) mod strict;
pub mod validation;

use fhir_types::codec::DecodeError;
use fhir_types::codec::EncodeError;
use fhir_types::codec::Json;
use fhir_types::codec::Object;
use fhir_types::codec::Path;
use fhir_types::codec::Value;
use fhir_types::r4::bundle::Bundle;
use fhir_types::r4::composition::Composition;
use fhir_types::r4::patient::Patient;
use fhir_types::r4::reference::Reference;
use fhir_types::r4::resource::Resource;

use crate::receive::reference::ReferenceError;
use crate::receive::reference::Target;

/// The `Bundle.type` of a document
/// (<https://hl7.org/fhir/R4/valueset-bundle-type.html>).
pub const DOCUMENT: &str = "document";

/// A document received in the exchange format, read and held to the R4
/// document rules.
#[derive(Debug, Clone)]
pub struct ReceivedDocument {
    original: String,
    tree: Object,
    bundle: Bundle,
    composition: Box<Composition>,
    patient: Box<Patient>,
}

impl ReceivedDocument {
    /// Reads a document from its FHIR JSON text.
    ///
    /// The text is read once. An object that repeats a name is refused
    /// first, because two JSON readers may keep different values for it
    /// (RFC 8259 §4). The text is then decoded against the R4 `Bundle` and
    /// the definitions of every resource it carries, which refuse an unknown
    /// property, a value of the wrong type and an invalid primitive
    /// (<https://hl7.org/fhir/R4/json.html>), and the decoded document must
    /// encode back to the JSON it was read from, so nothing the model does
    /// not hold is left in the text. Every later rule, the profile check and
    /// the mapping read that decoded document and nothing else.
    ///
    /// The `Bundle` must then be a `document` with the rules the R4 `Bundle`
    /// states for one: an `identifier` with a system and a value (`bdl-9`), a
    /// `timestamp` (`bdl-10`), `fullUrl`s that are given once (`bdl-7`), not
    /// version specific (`bdl-8`) and agree with their resource, and a
    /// `Composition` as its first entry (`bdl-11`)
    /// (<https://hl7.org/fhir/R4/bundle.html>). The `Composition.subject`
    /// must name the one `Patient` entry the document is about, and every
    /// other `subject` and `patient` reference in the document must name it
    /// too.
    ///
    /// # Errors
    ///
    /// Returns [`ReceiveError`] naming the first rule the text breaks. No
    /// refusal's message quotes a value of the document.
    pub fn read(text: &str) -> Result<Self, ReceiveError> {
        let (bundle, tree) = read_bundle(text)?;
        if bundle.r#type.value.as_deref() != Some(DOCUMENT) {
            return Err(ReceiveError::NotADocument {
                found: bundle.r#type.value.clone(),
            });
        }
        let identified = bundle.identifier.as_ref().is_some_and(|identifier| {
            identifier
                .system
                .as_ref()
                .is_some_and(|system| system.value.is_some())
                && identifier
                    .value
                    .as_ref()
                    .is_some_and(|value| value.value.is_some())
        });
        if !identified {
            return Err(ReceiveError::Unidentified);
        }
        if bundle
            .timestamp
            .as_ref()
            .and_then(|timestamp| timestamp.value.as_ref())
            .is_none()
        {
            return Err(ReceiveError::Undated);
        }
        entries::known_resources(&tree, "Bundle")?;
        entries::full_urls(&tree)?;
        Self::from_parts(text.to_owned(), tree, bundle)
    }

    /// Returns the document `bundle`, decoded from `tree` and read from
    /// `original`, with its first entry's `Composition` and the `Patient`
    /// its subject names.
    ///
    /// # Errors
    ///
    /// Returns [`ReceiveError::NoComposition`] when the first entry is no
    /// `Composition`, and the refusal of a subject that names no one
    /// `Patient` entry.
    fn from_parts(original: String, tree: Object, bundle: Bundle) -> Result<Self, ReceiveError> {
        let Some(Resource::Composition(composition)) = bundle
            .entry
            .first()
            .and_then(|entry| entry.resource.as_ref())
        else {
            return Err(ReceiveError::NoComposition);
        };
        let composition = composition.clone();
        let index = entries::patient(&tree)?;
        let Some(Resource::Patient(patient)) = bundle
            .entry
            .get(index)
            .and_then(|entry| entry.resource.as_ref())
        else {
            return Err(ReceiveError::SubjectUnresolved);
        };
        let patient = patient.clone();
        Ok(Self {
            original,
            tree,
            bundle,
            composition,
            patient,
        })
    }

    /// Returns the document's text exactly as it was received.
    #[must_use]
    pub fn original(&self) -> &str {
        &self.original
    }

    /// Returns the decoded document.
    #[must_use]
    pub const fn bundle(&self) -> &Bundle {
        &self.bundle
    }

    /// Returns the document's `Composition`, its first entry.
    #[must_use]
    pub fn composition(&self) -> &Composition {
        &self.composition
    }

    /// Returns the `Patient` the document is about, the entry its
    /// `Composition.subject` names.
    #[must_use]
    pub fn patient(&self) -> &Patient {
        &self.patient
    }

    /// Resolves `reference`, held by the resource of the entry at `holder`,
    /// with the one resolver every rule of the receive path uses
    /// ([`reference::resolve`]).
    ///
    /// # Errors
    ///
    /// Returns the [`ReferenceError`] that keeps the reference from naming
    /// exactly one entry of the document.
    pub fn resolve(&self, holder: usize, reference: &Reference) -> Result<Target, ReferenceError> {
        let reference =
            Json::to_json(reference).map_err(|source| ReferenceError::Unencodable { source })?;
        reference::resolve_in(&reference::entries(&self.tree), holder, &reference)
    }

    /// Returns the JSON the decoded document encodes as, equal to the JSON
    /// it was read from.
    pub(crate) const fn tree(&self) -> &Object {
        &self.tree
    }
}

/// Reads `text` as one R4 `Bundle`, refusing a repeated name, anything the
/// R4 model does not hold and a text that reads differently through it, and
/// answers the decoded Bundle with the JSON it encodes to.
fn read_bundle(text: &str) -> Result<(Bundle, Object), ReceiveError> {
    strict::unique_names(text).map_err(|source| {
        if source.is_data() {
            ReceiveError::RepeatedName { source }
        } else {
            ReceiveError::Json { source }
        }
    })?;
    let value: Value =
        serde_json::from_str(text).map_err(|source| ReceiveError::Json { source })?;
    let Value::Object(read) = value else {
        return Err(ReceiveError::NotAnObject);
    };
    if read.get("resourceType").and_then(Value::as_str) != Some("Bundle") {
        return Err(ReceiveError::NotABundle);
    }
    let bundle = Bundle::from_json(&read, &mut Path::root("Bundle"))
        .map_err(|source| ReceiveError::Decode { source })?;
    let tree = Json::to_json(&bundle).map_err(|source| ReceiveError::Encode { source })?;
    if tree != read {
        return Err(ReceiveError::Unfaithful);
    }
    Ok((bundle, tree))
}

/// Resources received in the exchange format outside a document: a FHIR R4
/// `collection` Bundle, read as strictly as a document.
///
/// HL7 Europe MPD 1.0.0 defines no document profile, and its prescription
/// and dispense examples are `collection` Bundles, so a dispense arrives as
/// one with the resources it references. The collection keeps the text it
/// was read from, the original a mapping into openEHR keeps beside its
/// content.
#[derive(Debug, Clone)]
pub struct ReceivedResources {
    original: String,
    bundle: Bundle,
}

impl ReceivedResources {
    /// Reads a collection from its FHIR JSON text.
    ///
    /// The text is read as [`ReceivedDocument::read`] reads a document: a
    /// repeated name, a property R4 does not define, a resource of a type R4
    /// does not define and a text that reads differently through the R4
    /// model are refused. The `Bundle` must then be of type `collection`
    /// (<https://hl7.org/fhir/R4/valueset-bundle-type.html>).
    ///
    /// # Errors
    ///
    /// Returns [`ReceiveError`] naming the first rule the text breaks, and
    /// [`ReceiveError::NotACollection`] for a Bundle of another type.
    pub fn read(text: &str) -> Result<Self, ReceiveError> {
        let (bundle, tree) = read_bundle(text)?;
        if bundle.r#type.value.as_deref() != Some(COLLECTION) {
            return Err(ReceiveError::NotACollection {
                found: bundle.r#type.value.clone(),
            });
        }
        entries::known_resources(&tree, "Bundle")?;
        Ok(Self {
            original: text.to_owned(),
            bundle,
        })
    }

    /// Returns the text the collection was read from.
    #[must_use]
    pub fn original(&self) -> &str {
        &self.original
    }

    /// Returns the decoded collection.
    #[must_use]
    pub const fn bundle(&self) -> &Bundle {
        &self.bundle
    }
}

/// The `Bundle.type` of a collection
/// (<https://hl7.org/fhir/R4/valueset-bundle-type.html>).
pub const COLLECTION: &str = "collection";

/// Why a received text is not a document this component reads.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ReceiveError {
    /// The text is not JSON.
    #[error("the document is not JSON")]
    Json {
        /// The parse failure.
        #[source]
        source: serde_json::Error,
    },
    /// An object of the document repeats a name, which two JSON readers may
    /// read as two different documents (RFC 8259 §4).
    #[error("an object of the document repeats a name")]
    RepeatedName {
        /// The parse failure.
        #[source]
        source: serde_json::Error,
    },
    /// The JSON is not an object.
    #[error("the document is not a JSON object")]
    NotAnObject,
    /// The object's `resourceType` is not `Bundle`.
    #[error("the document is not a Bundle")]
    NotABundle,
    /// The Bundle does not decode against the R4 definitions.
    #[error("the Bundle does not decode against FHIR R4")]
    Decode {
        /// The decode failure, located by element path.
        #[source]
        source: DecodeError,
    },
    /// The decoded Bundle cannot be encoded as JSON again.
    #[error("the decoded Bundle cannot be encoded as JSON")]
    Encode {
        /// The encode failure.
        #[source]
        source: EncodeError,
    },
    /// The decoded Bundle encodes as JSON other than the text it was read
    /// from: the text holds something the R4 model does not.
    #[error("the document reads differently through the R4 model")]
    Unfaithful,
    /// The Bundle's type is not `document`.
    #[error("the Bundle is of type {found:?}, not a document")]
    NotADocument {
        /// The type the Bundle carries, a code of the R4 value set.
        found: Option<String>,
    },
    /// The Bundle's type is not `collection`, the form resources outside a
    /// document are received in.
    #[error("the Bundle is of type {found:?}, not a collection")]
    NotACollection {
        /// The type the Bundle carries, a code of the R4 value set.
        found: Option<String>,
    },
    /// The document carries no identifier with a system and a value
    /// (`bdl-9`).
    #[error("the document carries no identifier with a system and a value (bdl-9)")]
    Unidentified,
    /// The document carries no timestamp (`bdl-10`).
    #[error("the document carries no timestamp (bdl-10)")]
    Undated,
    /// The document's first entry is no `Composition` (`bdl-11`).
    #[error("the document's first entry is no Composition (bdl-11)")]
    NoComposition,
    /// The `Composition` names no subject.
    #[error("the document's Composition names no subject")]
    NoSubject,
    /// The subject resolves to no single `Patient` entry of the document.
    #[error("the document's subject is no single Patient entry of the document")]
    SubjectUnresolved,
    /// A resource of the document is of a type R4 does not define.
    #[error("{location} is a resource of a type FHIR R4 does not define")]
    UnknownResource {
        /// The element path of the resource.
        location: String,
    },
    /// An entry's `fullUrl` is version specific (`bdl-8`).
    #[error("entry {entry} has a version-specific fullUrl (bdl-8)")]
    VersionedFullUrl {
        /// The entry's index.
        entry: usize,
    },
    /// An entry repeats the `fullUrl` and version of an earlier one
    /// (`bdl-7`).
    #[error("entry {entry} repeats the fullUrl of an earlier entry (bdl-7)")]
    DuplicateFullUrl {
        /// The entry's index.
        entry: usize,
    },
    /// An entry's REST-style `fullUrl` disagrees with its resource's type or
    /// id.
    #[error("entry {entry} has a fullUrl that disagrees with its resource's type or id")]
    FullUrlMismatch {
        /// The entry's index.
        entry: usize,
    },
    /// An entry other than the subject is a `Patient`.
    #[error("entry {entry} is a second Patient")]
    SeveralPatients {
        /// The entry's index.
        entry: usize,
    },
    /// A resource of the document contains a `Patient`.
    #[error("{location} contains a Patient")]
    ContainedPatient {
        /// The element path of the contained resource.
        location: String,
    },
    /// A subject element (`subject`, `patient`, `beneficiary`, `for`)
    /// resolves to anything but the document's `Patient` entry.
    #[error("{location} names a subject other than the document's Patient entry")]
    SubjectMismatch {
        /// The element path of the reference.
        location: String,
    },
    /// A reference the resolver refuses, and which may not name a resource
    /// outside the document.
    #[error("{location} is a reference the document cannot resolve")]
    Reference {
        /// The element path of the reference.
        location: String,
        /// Why it does not resolve.
        #[source]
        source: ReferenceError,
    },
    /// An entry's `fullUrl` is not spelled in the one canonical form.
    #[error("entry {entry} has a fullUrl not spelled in its canonical form")]
    NonCanonicalFullUrl {
        /// The entry's index.
        entry: usize,
    },
    /// An element the R4 element table does not describe, which no rule can
    /// read.
    #[error("{location} is an element the R4 element table does not describe")]
    Unreadable {
        /// The element path.
        location: String,
    },
}