openehr-its 0.0.128

openEHR ITS (Implementation Technology Specifications): canonical JSON + canonical XML serialization of the RM, the generated ITS-REST 1.1.0 API contract, and the OPT 1.4 and AOM2 archetype XML codecs
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
// SPDX-FileCopyrightText: Cadasto B.V.
// SPDX-FileCopyrightText: openEHR Foundation
// SPDX-License-Identifier: Apache-2.0

//! **ITS-JSON** — canonical JSON serialization: the named entry points.
//!
//! The canonical conventions (`_type` first on every object, snake_case keys,
//! omitted nulls/empties, integer-vs-real number typing, `{_type, value}` UIDs,
//! inline-base64 `DV_MULTIMEDIA.data`) are carried by the EMITTED manual
//! `serde::Serialize`/`serde::Deserialize` impls on the spec types themselves
//! (`openehr-codegen -- emit-json`, one `json_serde` module per spec crate over
//! the `openehr_base::serde_support` runtime) — never a serde derive, whose
//! four enum representations cannot express the canonical `_type` discriminator
//! (<https://serde.rs/enum-representations.html>). These functions are the
//! single named entry points so call sites read as canonical-JSON rather than
//! raw `serde_json`, and so every read is wrapped once in
//! `serde_path_to_error`.

#![expect(
    clippy::disallowed_types,
    reason = "canonical JSON / Simplified Formats operate on the wire value by definition \
              (ITS-JSON; ITS-REST Simplified Formats) — serde_json::Value IS the subject matter \
              (#1694)"
)]

use serde::Serialize;
use serde::de::DeserializeOwned;

/// The vendored ITS-JSON RM all-schema (draft-07), used to validate output in
/// the fidelity-gate tests, vendored at a pinned upstream commit (see the
/// bundled schema's provenance record).
#[cfg(feature = "schema-validation")]
pub const RM_SCHEMA_JSON: &str = include_str!("../schemas/json/openehr_rm_1.1.0_all.json");

/// A canonical-JSON deserialization failure: the message, the JSON path to the
/// offending node, and (for a syntax error) the source line/column.
///
/// The three are kept as separate DATA rather than one flattened sentence, so
/// the protocol edge can render them into a structured error body instead of
/// re-parsing prose.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JsonParseError {
    message: String,
    /// `Some((line, column))`, 1-based, for a failure `serde_json` located in
    /// the source text.
    location: Option<(usize, usize)>,
    /// JSON path segments from the root to the failing node, outermost first
    /// (e.g. `.content`, `[0]`, `.data`). Empty for a root-level failure.
    path: Vec<String>,
}

impl JsonParseError {
    /// A failure with a message and no location (the path is filled in as it
    /// propagates up through [`Self::in_field`] / [`Self::in_index`]).
    #[must_use]
    pub fn custom(message: impl Into<String>) -> Self {
        Self {
            message: message.into(),
            location: None,
            path: Vec::new(),
        }
    }

    /// An undeclared wire key on a concrete type, as raised by the pre-decode
    /// key walk ([`reject_undeclared_keys`]) and by the validation dispatcher.
    ///
    /// The wording is byte-identical to what the EMITTED reader raises for the
    /// same key (`openehr_base::serde_support::unknown_field`): the two doors
    /// are two implementations of one refusal, and the fast-vs-typed
    /// equivalence battery compares their messages directly.
    #[must_use]
    pub fn unknown_field(field: &str, ty: &str, known: &[&str]) -> Self {
        if known.is_empty() {
            return Self::custom(format!(
                "unknown field `{field}` on `{ty}`, there are no fields"
            ));
        }
        let list = known
            .iter()
            .map(|name| format!("`{name}`"))
            .collect::<Vec<_>>()
            .join(", ");
        Self::custom(format!(
            "unknown field `{field}` on `{ty}`, expected one of {list}"
        ))
    }

    /// Prepend a `.field` path segment (called as the failure propagates out of
    /// a named struct field).
    #[must_use]
    pub fn in_field(mut self, field: &str) -> Self {
        self.path.insert(0, format!(".{field}"));
        self
    }

    /// Prepend an `[index]` path segment (called as the failure propagates out
    /// of an array element).
    #[must_use]
    pub fn in_index(mut self, index: usize) -> Self {
        self.path.insert(0, format!("[{index}]"));
        self
    }

    /// The failure message, without the path or location decoration.
    #[must_use]
    pub fn message(&self) -> &str {
        &self.message
    }

    /// The JSON path segments from the root to the offending node, outermost
    /// first.
    #[must_use]
    pub fn path(&self) -> &[String] {
        &self.path
    }

    /// The 1-based `(line, column)` of the failure in the source text, when
    /// `serde_json` located one.
    #[must_use]
    pub fn location(&self) -> Option<(usize, usize)> {
        self.location
    }
}

/// Build the canonical error from a `serde_path_to_error` failure: the path
/// segments come from the tracker, the line/column from `serde_json`.
fn from_path_error(error: &serde_path_to_error::Error<serde_json::Error>) -> JsonParseError {
    let inner = error.inner();
    let path: Vec<String> = error
        .path()
        .iter()
        .map(|segment| match segment {
            serde_path_to_error::Segment::Seq { index } => format!("[{index}]"),
            serde_path_to_error::Segment::Map { key } => format!(".{key}"),
            serde_path_to_error::Segment::Enum { variant } => format!(".{variant}"),
            serde_path_to_error::Segment::Unknown => ".?".to_owned(),
        })
        .collect();
    // `serde_json` reports (0, 0) for a failure it could not locate in the
    // source (every error raised from a `Value` deserializer, for instance).
    let location = match (inner.line(), inner.column()) {
        (0, _) => None,
        (line, column) => Some((line, column)),
    };
    JsonParseError {
        message: inner.to_string(),
        location,
        path,
    }
}

impl std::fmt::Display for JsonParseError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        // `serde_json`'s own message already ends with `at line N column M`
        // where it located the failure, so the parsed-out location is kept as
        // queryable DATA ([`Self::location`]) rather than re-rendered here.
        write!(f, "{}", self.message)?;
        if !self.path.is_empty() {
            write!(f, " (at ${})", self.path.join(""))?;
        }
        Ok(())
    }
}

impl std::error::Error for JsonParseError {}

/// Serialize an RM value to canonical JSON (compact).
///
/// Serialization is infallible for the spec types, so this returns a `String`
/// directly (see the `expect` reason below).
///
/// # Panics
/// Never for a spec type: the only `Err` `serde_json` can return here is an
/// I/O error (the writer is an in-memory `String`), a non-string map key, or a
/// `Serialize` impl that returns `Err` — none of which the emitted spec-type
/// impls can produce.
#[must_use]
#[expect(
    clippy::expect_used,
    reason = "`serde_json::to_string` can only fail on an I/O error (the writer is an in-memory String), a non-string map key, or a `Serialize` impl that returns Err; the emitted spec-type impls key every map with `String` and never return Err, so the Err variant is unreachable here — the Book ch9 sanctioned escape for a logically-impossible Err"
)]
pub fn to_canonical_json<T: Serialize + ?Sized>(value: &T) -> String {
    serde_json::to_string(value).expect("an openEHR spec value should always serialize")
}

/// Serialize an RM value to a canonical-JSON `serde_json::Value` (for the
/// boundary where the caller needs an in-memory tree).
///
/// The workspace pins `serde_json/preserve_order`, so the resulting object
/// keeps the canonical member order (`_type` first, then BMM declaration
/// order).
///
/// # Panics
/// Never for a spec type: `serde_json::to_value` fails only on a non-string map
/// key or a `Serialize` impl that returns `Err`, and the emitted spec-type
/// impls do neither.
#[must_use]
#[expect(
    clippy::expect_used,
    reason = "`serde_json::to_value` fails only on a non-string map key or a `Serialize` impl that returns Err; the emitted spec-type impls do neither, so the Err variant is unreachable here — the Book ch9 sanctioned escape for a logically-impossible Err"
)]
pub fn to_canonical_value<T: Serialize + ?Sized>(value: &T) -> serde_json::Value {
    serde_json::to_value(value).expect("an openEHR spec value should always serialize")
}

/// Deserialize an RM value from canonical JSON (`&str`).
///
/// The reader is STRICT: an undeclared wire key, a repeated key, an absent
/// mandatory attribute, a present-but-wrong `_type` and a malformed identifier
/// are all refusals. A missing `_type` on a concretely-typed slot is tolerated
/// (per ITS-JSON), members may arrive in any order, and trailing content after
/// the value is rejected.
///
/// # Errors
/// Returns a [`JsonParseError`] carrying the JSON path to the offending node.
pub fn from_canonical_json<T: DeserializeOwned>(json: &str) -> Result<T, JsonParseError> {
    // The happy path reads WITHOUT the path tracker: `serde_path_to_error`
    // wraps every key and value seed, and on this corpus that costs ~60% of
    // total read time. A failed read re-runs the same deterministic decode WITH
    // the tracker to build the diagnostic — so the path is never lost, and it
    // is paid for only when there is something to report.
    match serde_json::from_str::<T>(json) {
        Ok(value) => Ok(value),
        Err(plain) => Err(located_in_str::<T>(json, &plain)),
    }
}

/// Re-read `json` with the path tracker so a refusal names the offending node.
fn located_in_str<T: DeserializeOwned>(json: &str, plain: &serde_json::Error) -> JsonParseError {
    let mut deserializer = serde_json::Deserializer::from_str(json);
    match serde_path_to_error::deserialize::<_, T>(&mut deserializer) {
        Err(located) => from_path_error(&located),
        // A deterministic re-read of the same bytes cannot succeed where the
        // first read failed; if it ever does, the original diagnostic still
        // describes the failure — just without its path.
        Ok(_) => JsonParseError::custom(plain.to_string()),
    }
}

/// Deserialize an RM value from an already-parsed canonical-JSON
/// `serde_json::Value` (no re-stringifying — `&Value` is itself a
/// `Deserializer`). Same strictness as [`from_canonical_json`].
///
/// # Errors
/// Returns a [`JsonParseError`] carrying the JSON path to the offending node.
pub fn from_canonical_value<T: DeserializeOwned>(
    value: &serde_json::Value,
) -> Result<T, JsonParseError> {
    // Same two-phase shape as [`from_canonical_json`]: the untracked read on
    // the happy path, the tracked re-read only to describe a failure.
    match T::deserialize(value) {
        Ok(decoded) => Ok(decoded),
        Err(plain) => Err(match serde_path_to_error::deserialize::<_, T>(value) {
            Err(located) => from_path_error(&located),
            Ok(_) => JsonParseError::custom(plain.to_string()),
        }),
    }
}

/// The compiled ITS-JSON validator, built once from [`RM_SCHEMA_JSON`]. Stored
/// as a `Result` (not `expect`ed) so a schema-compile failure surfaces as a
/// validation error rather than a panic in library code.
///
/// NOTE: the two causes stay flattened into the stored `String` rather than
/// carried as a source (RFC 0201) — the schema is a compiled-in constant, so a
/// failure here is a packaging fault with no caller that could branch on it.
#[cfg(feature = "schema-validation")]
static RM_VALIDATOR: std::sync::LazyLock<Result<jsonschema::Validator, String>> =
    std::sync::LazyLock::new(|| {
        let schema: serde_json::Value =
            serde_json::from_str(RM_SCHEMA_JSON).map_err(|e| format!("parse RM schema: {e}"))?;
        jsonschema::validator_for(&schema).map_err(|e| format!("compile RM schema: {e}"))
    });

/// Refuse the first `_type`-tagged node of `value` that carries a wire key its
/// class does not declare — the **wire door's** half of the strict reader.
///
/// The protocol adapter parses a canonical-JSON body into an untyped
/// `serde_json::Value` (so a committed document keeps the client's bytes
/// verbatim) and therefore never runs a typed decode at the door. This walk
/// gives that door the reader's undeclared-key refusal WITHOUT a decode, using
/// the generated `declared_fields` table — the same field view the typed reader
/// refuses from, so the two can never disagree.
///
/// Only `_type`-tagged nodes are judged: canonical JSON requires `_type` only
/// where the declared attribute type is abstract, so an untagged node's class
/// is not knowable from the value alone here. An undeclared key on such a node
/// is still refused, one tier later, by the typed dispatch
/// (`crate::wire_validate`) — this door narrows *which status* a refusal
/// carries, never *whether* one happens.
///
/// # Why the door and not validation
/// A document the reader cannot READ never converts, so it cannot reach the
/// convertible-but-semantically-invalid branch: ITS-REST overview
/// `Requests_and_responses.md` §HTTP status codes defines `400` as content that
/// "could not be parsed or is invalid" and `422` as content that is
/// "well-formed but was unable to be followed due to semantic errors".
///
/// # Errors
/// A [`JsonParseError`] naming the offending key, the class that does not
/// declare it, and the path to the node.
pub fn reject_undeclared_keys(value: &serde_json::Value) -> Result<(), JsonParseError> {
    match value {
        serde_json::Value::Object(map) => {
            if let Some(ty) = map.get("_type").and_then(serde_json::Value::as_str)
                && let Some(declared) =
                    crate::json_codec::generated::structural::declared_fields(ty)
                && let Some(key) = map
                    .keys()
                    .find(|k| k.as_str() != "_type" && declared.binary_search(&k.as_str()).is_err())
            {
                return Err(JsonParseError::unknown_field(key, ty, declared).in_field(key));
            }
            for (key, child) in map {
                reject_undeclared_keys(child).map_err(|e| e.in_field(key))?;
            }
            Ok(())
        }
        serde_json::Value::Array(items) => {
            for (index, child) in items.iter().enumerate() {
                reject_undeclared_keys(child).map_err(|e| e.in_index(index))?;
            }
            Ok(())
        }
        _ => Ok(()),
    }
}

/// The ITS-JSON validator whose root dispatches on the top-level `_type` to
/// EVERY class definition of [`RM_SCHEMA_JSON`], the published root listing
/// only some of them.
#[cfg(feature = "schema-validation")]
static RM_CLASS_VALIDATOR: std::sync::LazyLock<Result<jsonschema::Validator, String>> =
    std::sync::LazyLock::new(|| {
        let mut schema: serde_json::Value =
            serde_json::from_str(RM_SCHEMA_JSON).map_err(|e| format!("parse RM schema: {e}"))?;
        let names: Vec<String> = schema
            .get("definitions")
            .and_then(serde_json::Value::as_object)
            .map(|definitions| definitions.keys().cloned().collect())
            .ok_or_else(|| "the RM schema has no definitions".to_owned())?;
        // The schema keyword that names a class, never a canonical shape's own key.
        let discriminator = "_type";
        let mut root = vec![serde_json::json!({
            "required": [discriminator],
            "properties": { (discriminator): { "enum": names } }
        })];
        root.extend(names.iter().map(|class| {
            serde_json::json!({
                "if": { "properties": { (discriminator): { "const": class } } },
                "then": { "$ref": format!("#/definitions/{class}") }
            })
        }));
        if let Some(map) = schema.as_object_mut() {
            map.insert("allOf".to_owned(), serde_json::Value::Array(root));
        }
        jsonschema::validator_for(&schema).map_err(|e| format!("compile RM schema: {e}"))
    });

/// Which root of the vendored ITS-JSON RM schema (`openehr_rm_1.1.0_all.json`)
/// judges a value.
#[cfg(feature = "schema-validation")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SchemaRoot {
    /// The published root: it dispatches on the top-level `_type` (draft-07
    /// `if`/`then`) to the matching definition for the classes it lists, and a
    /// value of any other class passes it unjudged.
    Published,
    /// A root that dispatches on the top-level `_type` to every class
    /// definition the schema holds.
    EveryClass,
}

/// Validate a canonical-JSON value against the vendored ITS-JSON RM schema,
/// under the given `root`.
///
/// # Errors
/// Returns every schema violation (path + message), or a single-element error if
/// the schema itself failed to compile.
#[cfg(feature = "schema-validation")]
pub fn validate_canonical(value: &serde_json::Value, root: SchemaRoot) -> Result<(), Vec<String>> {
    let validator = match root {
        SchemaRoot::Published => &RM_VALIDATOR,
        SchemaRoot::EveryClass => &RM_CLASS_VALIDATOR,
    };
    collect_errors(validator, value)
}

#[cfg(feature = "schema-validation")]
fn collect_errors(
    validator: &Result<jsonschema::Validator, String>,
    value: &serde_json::Value,
) -> Result<(), Vec<String>> {
    match validator {
        Ok(validator) => {
            let errors: Vec<String> = validator
                .iter_errors(value)
                .map(|e| format!("{} (at {})", e, e.instance_path()))
                .collect();
            if errors.is_empty() {
                Ok(())
            } else {
                Err(errors)
            }
        }
        Err(e) => Err(vec![e.clone()]),
    }
}