Skip to main content

helios_sof/
fhir_format.rs

1//! Shared `fhir`-format output and FHIR-representation content negotiation
2//! for the SQL-on-FHIR run operations (SoF v2 Common Operation Behavior).
3//!
4//! Two independent negotiation axes are implemented here so `sof-server` and
5//! the HFS REST handlers apply identical rules:
6//!
7//! - **Axis 1 — format selection**: `_format=fhir` (or
8//!   `Accept: application/fhir+json` when `_format` is absent) selects the
9//!   `fhir` output format: a `Parameters` resource with one repeating `row`
10//!   parameter per result row ([`format_view_fhir_parameters`]).
11//! - **Axis 2 — representation**: when a *flat* format is selected and the
12//!   client sends `Accept: application/fhir+json`, the raw payload is wrapped
13//!   in a serialized `Binary` resource envelope with base64 `data`
14//!   ([`wrap_in_binary_envelope`]). The XML envelope form
15//!   (`application/fhir+xml`) is not supported and callers should reject it
16//!   with `406 Not Acceptable` (see [`accept_requires_unsupported_fhir_xml`]).
17
18use base64::Engine as _;
19use serde_json::{Value, json};
20
21use crate::sqlquery::engine::TableSchema;
22use crate::sqlquery::output::value_to_fhir_part;
23use crate::sqlquery::{ColumnFhirType, SqlQueryError};
24use crate::{ProcessedResult, SofError};
25
26/// Native media type of the `fhir` output format.
27pub const FHIR_JSON_MIME: &str = "application/fhir+json";
28
29/// The FHIR XML media type — recognised only to reject it explicitly.
30pub const FHIR_XML_MIME: &str = "application/fhir+xml";
31
32/// True when the given `Accept` header value lists `mime` (parameters such as
33/// `;q=` are ignored; matching is case-insensitive).
34pub fn accept_has_mime(accept: Option<&str>, mime: &str) -> bool {
35    let Some(accept) = accept else {
36        return false;
37    };
38    accept
39        .split(',')
40        .map(|s| s.split(';').next().unwrap_or("").trim())
41        .any(|m| m.eq_ignore_ascii_case(mime))
42}
43
44/// True when the client asked for a FHIR XML representation without also
45/// accepting FHIR JSON. Per the spec, a server that does not support the
46/// envelope form requested via `Accept` SHALL respond `406 Not Acceptable`
47/// rather than silently returning raw bytes under a FHIR media type; this
48/// server does not produce XML.
49pub fn accept_requires_unsupported_fhir_xml(accept: Option<&str>) -> bool {
50    accept_has_mime(accept, FHIR_XML_MIME) && !accept_has_mime(accept, FHIR_JSON_MIME)
51}
52
53/// Wraps raw payload bytes in a serialized FHIR `Binary` resource envelope
54/// (`contentType` = the payload's native media type, `data` = base64). The
55/// caller serves the result under `application/fhir+json`.
56pub fn wrap_in_binary_envelope(content_type: &str, payload: &[u8]) -> Result<Vec<u8>, SofError> {
57    // `Binary.contentType` carries the bare media type — strip any
58    // parameters (e.g. `; charset=utf-8`) from HTTP header values.
59    let media_type = content_type
60        .split(';')
61        .next()
62        .unwrap_or(content_type)
63        .trim();
64    let binary = json!({
65        "resourceType": "Binary",
66        "contentType": media_type,
67        "data": base64::engine::general_purpose::STANDARD.encode(payload),
68    });
69    serde_json::to_vec(&binary).map_err(SofError::SerializationError)
70}
71
72/// Renders a `ProcessedResult` as a FHIR `Parameters` resource per the SoF v2
73/// spec's `fhir` output format: one top-level `row` parameter per result row,
74/// with one `part` per non-NULL column carrying the appropriate `value[x]`.
75///
76/// The `value[x]` choice is driven by the ViewDefinition's declared
77/// `column.type` (collected from `view_json`, including nested `select` and
78/// `unionAll` branches); columns without a declared type default to `string`.
79/// NULL cells are omitted. Collection (array) cells repeat the part once per
80/// element, matching FHIR's repeating-element semantics. An empty result emits
81/// a bare `{"resourceType": "Parameters"}` with the `parameter` key omitted.
82pub fn format_view_fhir_parameters(
83    result: &ProcessedResult,
84    view_json: &Value,
85) -> Result<Vec<u8>, SofError> {
86    let schema = TableSchema::from_view_definition(view_json);
87    let column_types: Vec<ColumnFhirType> = result
88        .columns
89        .iter()
90        .map(|name| {
91            schema
92                .columns
93                .iter()
94                .find(|c| &c.name == name)
95                .map(|c| c.fhir_type.clone())
96                .unwrap_or_else(|| ColumnFhirType::String("string".into()))
97        })
98        .collect();
99
100    let mut row_params: Vec<Value> = Vec::with_capacity(result.rows.len());
101    for row in &result.rows {
102        let mut parts: Vec<Value> = Vec::with_capacity(row.values.len());
103        for (i, cell) in row.values.iter().enumerate() {
104            let Some(value) = cell else {
105                continue; // NULL → omit the part
106            };
107            if value.is_null() {
108                continue;
109            }
110            let name = &result.columns[i];
111            let ty = &column_types[i];
112            match value {
113                Value::Array(items) => {
114                    for item in items {
115                        if item.is_null() {
116                            continue;
117                        }
118                        parts.push(part_or_sof_error(name, item, ty)?);
119                    }
120                }
121                other => parts.push(part_or_sof_error(name, other, ty)?),
122            }
123        }
124        row_params.push(json!({ "name": "row", "part": parts }));
125    }
126
127    let body = if row_params.is_empty() {
128        json!({ "resourceType": "Parameters" })
129    } else {
130        json!({ "resourceType": "Parameters", "parameter": row_params })
131    };
132    serde_json::to_vec(&body).map_err(SofError::SerializationError)
133}
134
135fn part_or_sof_error(name: &str, value: &Value, ty: &ColumnFhirType) -> Result<Value, SofError> {
136    value_to_fhir_part(name, value, ty).map_err(|e| match e {
137        SqlQueryError::UnsupportedFhirValue(col) => SofError::InvalidViewDefinition(format!(
138            "column '{col}' holds a complex value that cannot be represented as a \
139             FHIR value[x] part; the 'fhir' output format supports scalar columns only"
140        )),
141        other => SofError::InvalidViewDefinition(other.to_string()),
142    })
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use crate::ProcessedRow;
149    use serde_json::json;
150
151    fn view(columns: Value) -> Value {
152        json!({
153            "resourceType": "ViewDefinition",
154            "resource": "Patient",
155            "select": [{ "column": columns }]
156        })
157    }
158
159    #[test]
160    fn typed_columns_map_to_value_x() {
161        let result = ProcessedResult {
162            columns: vec!["id".into(), "active".into(), "age".into()],
163            rows: vec![ProcessedRow {
164                values: vec![Some(json!("p1")), Some(json!(true)), Some(json!(42))],
165            }],
166        };
167        let v = view(json!([
168            {"name": "id", "path": "id", "type": "id"},
169            {"name": "active", "path": "active", "type": "boolean"},
170            {"name": "age", "path": "age", "type": "integer"}
171        ]));
172        let bytes = format_view_fhir_parameters(&result, &v).unwrap();
173        let out: Value = serde_json::from_slice(&bytes).unwrap();
174        let part = &out["parameter"][0]["part"];
175        assert_eq!(part[0]["valueId"], json!("p1"));
176        assert_eq!(part[1]["valueBoolean"], json!(true));
177        assert_eq!(part[2]["valueInteger"], json!(42));
178    }
179
180    #[test]
181    fn untyped_column_defaults_to_string_and_null_is_omitted() {
182        let result = ProcessedResult {
183            columns: vec!["name".into(), "missing".into()],
184            rows: vec![ProcessedRow {
185                values: vec![Some(json!("Smith")), None],
186            }],
187        };
188        let v = view(json!([{"name": "name", "path": "name.family"}]));
189        let bytes = format_view_fhir_parameters(&result, &v).unwrap();
190        let out: Value = serde_json::from_slice(&bytes).unwrap();
191        let parts = out["parameter"][0]["part"].as_array().unwrap();
192        assert_eq!(parts.len(), 1);
193        assert_eq!(parts[0]["valueString"], json!("Smith"));
194    }
195
196    #[test]
197    fn collection_column_repeats_part_per_element() {
198        let result = ProcessedResult {
199            columns: vec!["given".into()],
200            rows: vec![ProcessedRow {
201                values: vec![Some(json!(["John", "Quincy"]))],
202            }],
203        };
204        let v = view(json!([
205            {"name": "given", "path": "name.given", "type": "string", "collection": true}
206        ]));
207        let bytes = format_view_fhir_parameters(&result, &v).unwrap();
208        let out: Value = serde_json::from_slice(&bytes).unwrap();
209        let parts = out["parameter"][0]["part"].as_array().unwrap();
210        assert_eq!(parts.len(), 2);
211        assert_eq!(parts[0]["name"], json!("given"));
212        assert_eq!(parts[0]["valueString"], json!("John"));
213        assert_eq!(parts[1]["valueString"], json!("Quincy"));
214    }
215
216    #[test]
217    fn empty_result_omits_parameter_key() {
218        let result = ProcessedResult {
219            columns: vec!["id".into()],
220            rows: vec![],
221        };
222        let v = view(json!([{"name": "id", "path": "id", "type": "id"}]));
223        let bytes = format_view_fhir_parameters(&result, &v).unwrap();
224        let out: Value = serde_json::from_slice(&bytes).unwrap();
225        assert_eq!(out["resourceType"], json!("Parameters"));
226        assert!(out.get("parameter").is_none());
227    }
228
229    #[test]
230    fn complex_value_errors() {
231        let result = ProcessedResult {
232            columns: vec!["name".into()],
233            rows: vec![ProcessedRow {
234                values: vec![Some(json!({"family": "Smith"}))],
235            }],
236        };
237        let v = view(json!([{"name": "name", "path": "name"}]));
238        let err = format_view_fhir_parameters(&result, &v).unwrap_err();
239        assert!(matches!(err, SofError::InvalidViewDefinition(_)));
240    }
241
242    #[test]
243    fn accept_matching() {
244        assert!(accept_has_mime(
245            Some("text/csv, application/fhir+json;q=0.9"),
246            FHIR_JSON_MIME
247        ));
248        assert!(accept_has_mime(
249            Some("Application/FHIR+JSON"),
250            FHIR_JSON_MIME
251        ));
252        assert!(!accept_has_mime(Some("application/json"), FHIR_JSON_MIME));
253        assert!(!accept_has_mime(None, FHIR_JSON_MIME));
254
255        assert!(accept_requires_unsupported_fhir_xml(Some(
256            "application/fhir+xml"
257        )));
258        assert!(!accept_requires_unsupported_fhir_xml(Some(
259            "application/fhir+xml, application/fhir+json"
260        )));
261        assert!(!accept_requires_unsupported_fhir_xml(Some("text/csv")));
262    }
263
264    #[test]
265    fn binary_envelope_round_trips() {
266        let bytes = wrap_in_binary_envelope("text/csv", b"a,b\n1,2\n").unwrap();
267        let v: Value = serde_json::from_slice(&bytes).unwrap();
268        assert_eq!(v["resourceType"], json!("Binary"));
269        assert_eq!(v["contentType"], json!("text/csv"));
270        let decoded = base64::engine::general_purpose::STANDARD
271            .decode(v["data"].as_str().unwrap())
272            .unwrap();
273        assert_eq!(decoded, b"a,b\n1,2\n");
274    }
275}