Skip to main content

helios_sof/
params.rs

1//! Shared SoF v2 `$viewdefinition-run` parameter extraction.
2//!
3//! Both the REST handler in `helios-rest` and the standalone sof-server walk a
4//! FHIR `Parameters` body for the same set of operation parameters
5//! (`_format`, `_limit`, `_since`, `patient`, `group`, `viewResource`,
6//! `viewReference`, `resource`, `header`, plus the Parquet options). This
7//! module owns the field-name list and the accepted JSON shapes so a new
8//! parameter name only needs to be added in one place.
9//!
10//! The extractor is **permissive**: missing / wrong-typed `value[X]` fields
11//! produce `None`/empty rather than an error. Strict callers (sof-server) run
12//! an additional validation pass on the same JSON for bounds checks
13//! (e.g. `_limit` upper bound, `compression` allowed values).
14
15use serde_json::Value;
16
17/// SoF v2 `$viewdefinition-run` parameters lifted out of a JSON `Parameters`
18/// resource. Scalar fields hold the first occurrence; `patient`, `group`,
19/// `inline_resources` collect every entry (spec is `0..*`).
20#[derive(Debug, Default, Clone)]
21pub struct ExtractedRunParams {
22    /// `_format` — `valueCode` or `valueString`.
23    pub format: Option<String>,
24    /// `header` — `valueBoolean` (preferred) or `valueString` (lenient).
25    pub header: Option<bool>,
26    /// `_limit` — `valueInteger` or `valuePositiveInt`.
27    pub limit: Option<u64>,
28    /// `_since` — `valueInstant`, `valueDateTime`, or `valueString`.
29    pub since: Option<String>,
30    /// `patient` — `valueReference.reference` or `valueString` (any number).
31    pub patient: Vec<String>,
32    /// `group` — `valueReference.reference` or `valueString` (any number).
33    pub group: Vec<String>,
34    /// `viewResource` — the inline `resource`.
35    pub view_resource: Option<Value>,
36    /// `viewReference` — `valueReference.reference` or `valueString`.
37    pub view_reference: Option<String>,
38    /// `resource` — every inline resource encountered (any number).
39    pub inline_resources: Vec<Value>,
40    /// `source` — `valueString` or `valueUri`.
41    pub source: Option<String>,
42    /// `maxFileSize` — `valueInteger` or `valuePositiveInt`.
43    pub max_file_size: Option<u64>,
44    /// `rowGroupSize` — `valueInteger` or `valuePositiveInt`.
45    pub row_group_size: Option<u64>,
46    /// `pageSize` — `valueInteger` or `valuePositiveInt`.
47    pub page_size: Option<u64>,
48    /// `compression` — `valueCode` or `valueString`.
49    pub compression: Option<String>,
50}
51
52/// Splits a comma-separated reference string into trimmed, non-empty
53/// entries. Used by both sof-server and HFS REST to lower a single
54/// `?group=Group/a,Group/b` query value into the spec's `0..*` shape.
55/// Returns an empty `Vec` when the input is `None` or yields no
56/// non-empty entries.
57pub fn split_csv_refs(value: Option<&str>) -> Vec<String> {
58    match value {
59        Some(s) => s
60            .split(',')
61            .map(|t| t.trim().to_string())
62            .filter(|t| !t.is_empty())
63            .collect(),
64        None => Vec::new(),
65    }
66}
67
68/// Returns `true` when `body` carries a ViewDefinition the caller can run
69/// directly — either a bare `ViewDefinition` resource or a `Parameters` body
70/// with a `viewResource` or `viewReference` parameter.
71pub fn body_has_view_definition(body: &Value) -> bool {
72    match body.get("resourceType").and_then(|v| v.as_str()) {
73        Some("ViewDefinition") => true,
74        Some("Parameters") => body
75            .get("parameter")
76            .and_then(|p| p.as_array())
77            .map(|params| {
78                params.iter().any(|p| {
79                    matches!(
80                        parameter_name(p).as_deref(),
81                        Some("viewResource") | Some("viewReference")
82                    )
83                })
84            })
85            .unwrap_or(false),
86        _ => false,
87    }
88}
89
90/// Walks a JSON `Parameters` body (or any object with a `parameter` array)
91/// and pulls every SoF v2 run-operation field into [`ExtractedRunParams`].
92///
93/// Returns an empty struct when `body` isn't a `Parameters` resource — call
94/// sites that may receive a bare `ViewDefinition` should detect that case
95/// separately (e.g. via [`body_has_view_definition`]). Repeated entries for
96/// the same scalar field keep the first value; `patient` / `group` /
97/// `inline_resources` accumulate.
98pub fn extract_run_params_from_json(body: &Value) -> ExtractedRunParams {
99    let mut out = ExtractedRunParams::default();
100
101    if body.get("resourceType").and_then(|v| v.as_str()) != Some("Parameters") {
102        return out;
103    }
104    let Some(entries) = body.get("parameter").and_then(|p| p.as_array()) else {
105        return out;
106    };
107
108    for p in entries {
109        let Some(name) = parameter_name(p) else {
110            continue;
111        };
112        match name.as_str() {
113            "_format" | "format" => {
114                if out.format.is_none() {
115                    out.format = read_str(p, &["valueCode", "valueString"]);
116                }
117            }
118            "header" => {
119                if out.header.is_none() {
120                    if let Some(b) = p.get("valueBoolean").and_then(|v| v.as_bool()) {
121                        out.header = Some(b);
122                    } else if let Some(s) = p.get("valueString").and_then(|v| v.as_str()) {
123                        out.header = Some(s == "true" || s == "1");
124                    }
125                }
126            }
127            "_limit" => {
128                if out.limit.is_none() {
129                    out.limit = p
130                        .get("valueInteger")
131                        .or_else(|| p.get("valuePositiveInt"))
132                        .and_then(|v| v.as_u64());
133                }
134            }
135            "_since" => {
136                if out.since.is_none() {
137                    out.since = read_str(p, &["valueInstant", "valueDateTime", "valueString"]);
138                }
139            }
140            "patient" => {
141                if let Some(s) = read_reference_or_string(p) {
142                    out.patient.push(s);
143                }
144            }
145            "group" => {
146                if let Some(s) = read_reference_or_string(p) {
147                    out.group.push(s);
148                }
149            }
150            "viewResource" => {
151                if out.view_resource.is_none() {
152                    if let Some(r) = p.get("resource") {
153                        out.view_resource = Some(r.clone());
154                    }
155                }
156            }
157            "viewReference" => {
158                if out.view_reference.is_none() {
159                    out.view_reference = read_reference_or_string(p);
160                }
161            }
162            "resource" => {
163                if let Some(r) = p.get("resource") {
164                    // Spec (Resource Parameter and Bundle Inputs): a `Bundle`
165                    // supplied as a `resource` value is unwrapped one level —
166                    // the view runs against each `Bundle.entry[*].resource`,
167                    // never against the `Bundle` itself. Mixing discrete
168                    // resources and bundles is permitted; the effective input
169                    // is their union.
170                    if r.get("resourceType").and_then(|v| v.as_str()) == Some("Bundle") {
171                        if let Some(entries) = r.get("entry").and_then(|e| e.as_array()) {
172                            for entry in entries {
173                                if let Some(res) = entry.get("resource") {
174                                    out.inline_resources.push(res.clone());
175                                }
176                            }
177                        }
178                    } else {
179                        out.inline_resources.push(r.clone());
180                    }
181                }
182            }
183            "source" => {
184                if out.source.is_none() {
185                    out.source = read_str(p, &["valueString", "valueUri"]);
186                }
187            }
188            "maxFileSize" => {
189                if out.max_file_size.is_none() {
190                    out.max_file_size = read_u64(p, &["valueInteger", "valuePositiveInt"]);
191                }
192            }
193            "rowGroupSize" => {
194                if out.row_group_size.is_none() {
195                    out.row_group_size = read_u64(p, &["valueInteger", "valuePositiveInt"]);
196                }
197            }
198            "pageSize" => {
199                if out.page_size.is_none() {
200                    out.page_size = read_u64(p, &["valueInteger", "valuePositiveInt"]);
201                }
202            }
203            "compression" => {
204                if out.compression.is_none() {
205                    out.compression = read_str(p, &["valueCode", "valueString"]);
206                }
207            }
208            _ => {}
209        }
210    }
211    out
212}
213
214/// Pulls a parameter's `name`. Accepts both raw-JSON shape (`"name": "..."`)
215/// and FHIR-typed serde output (`"name": {"value": "..."}`).
216fn parameter_name(p: &Value) -> Option<String> {
217    let raw = p.get("name")?;
218    if let Some(s) = raw.as_str() {
219        return Some(s.to_string());
220    }
221    if let Some(v) = raw.get("value").and_then(|v| v.as_str()) {
222        return Some(v.to_string());
223    }
224    None
225}
226
227fn read_str(p: &Value, keys: &[&str]) -> Option<String> {
228    for key in keys {
229        if let Some(s) = p.get(*key).and_then(|v| v.as_str()) {
230            return Some(s.to_string());
231        }
232    }
233    None
234}
235
236fn read_u64(p: &Value, keys: &[&str]) -> Option<u64> {
237    for key in keys {
238        if let Some(n) = p.get(*key).and_then(|v| v.as_u64()) {
239            return Some(n);
240        }
241    }
242    None
243}
244
245/// Reads a `valueReference.reference` (preferred) or `valueString` (fallback).
246fn read_reference_or_string(p: &Value) -> Option<String> {
247    if let Some(s) = p
248        .get("valueReference")
249        .and_then(|r| r.get("reference"))
250        .and_then(|v| v.as_str())
251    {
252        return Some(s.to_string());
253    }
254    p.get("valueString")
255        .and_then(|v| v.as_str())
256        .map(|s| s.to_string())
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use serde_json::json;
263
264    fn params(parameter: Vec<Value>) -> Value {
265        json!({"resourceType": "Parameters", "parameter": parameter})
266    }
267
268    #[test]
269    fn bare_viewdefinition_detected() {
270        assert!(body_has_view_definition(
271            &json!({"resourceType": "ViewDefinition"})
272        ));
273    }
274
275    #[test]
276    fn parameters_with_view_resource_detected() {
277        assert!(body_has_view_definition(&params(vec![json!({
278            "name": "viewResource",
279            "resource": {"resourceType": "ViewDefinition"}
280        })])));
281    }
282
283    #[test]
284    fn parameters_with_view_reference_detected() {
285        assert!(body_has_view_definition(&params(vec![json!({
286            "name": "viewReference",
287            "valueReference": {"reference": "ViewDefinition/x"}
288        })])));
289    }
290
291    #[test]
292    fn parameters_without_view_returns_false() {
293        assert!(!body_has_view_definition(&params(vec![json!({
294            "name": "patient",
295            "valueString": "Patient/123"
296        })])));
297    }
298
299    #[test]
300    fn empty_for_non_parameters_body() {
301        let p = extract_run_params_from_json(&json!({"resourceType": "ViewDefinition"}));
302        assert!(p.patient.is_empty());
303        assert!(p.format.is_none());
304    }
305
306    #[test]
307    fn format_accepts_code_and_string() {
308        let p = extract_run_params_from_json(&params(vec![
309            json!({"name": "_format", "valueCode": "csv"}),
310        ]));
311        assert_eq!(p.format.as_deref(), Some("csv"));
312        let p = extract_run_params_from_json(&params(vec![
313            json!({"name": "_format", "valueString": "csv"}),
314        ]));
315        assert_eq!(p.format.as_deref(), Some("csv"));
316    }
317
318    #[test]
319    fn header_boolean_or_string() {
320        let p = extract_run_params_from_json(&params(vec![
321            json!({"name": "header", "valueBoolean": true}),
322        ]));
323        assert_eq!(p.header, Some(true));
324        let p = extract_run_params_from_json(&params(vec![
325            json!({"name": "header", "valueString": "false"}),
326        ]));
327        assert_eq!(p.header, Some(false));
328    }
329
330    #[test]
331    fn limit_integer_or_positive_int() {
332        let p = extract_run_params_from_json(&params(vec![
333            json!({"name": "_limit", "valueInteger": 100}),
334        ]));
335        assert_eq!(p.limit, Some(100));
336        let p = extract_run_params_from_json(&params(vec![
337            json!({"name": "_limit", "valuePositiveInt": 50}),
338        ]));
339        assert_eq!(p.limit, Some(50));
340    }
341
342    #[test]
343    fn since_accepts_three_shapes() {
344        for key in ["valueInstant", "valueDateTime", "valueString"] {
345            let p = extract_run_params_from_json(&params(vec![
346                json!({"name": "_since", key: "2024-01-02T03:04:05Z"}),
347            ]));
348            assert_eq!(p.since.as_deref(), Some("2024-01-02T03:04:05Z"));
349        }
350    }
351
352    #[test]
353    fn patient_repeated_entries_accumulate() {
354        let p = extract_run_params_from_json(&params(vec![
355            json!({"name": "patient", "valueReference": {"reference": "Patient/1"}}),
356            json!({"name": "patient", "valueString": "Patient/2"}),
357        ]));
358        assert_eq!(
359            p.patient,
360            vec!["Patient/1".to_string(), "Patient/2".to_string()]
361        );
362    }
363
364    #[test]
365    fn group_repeated_entries_accumulate() {
366        let p = extract_run_params_from_json(&params(vec![
367            json!({"name": "group", "valueReference": {"reference": "Group/1"}}),
368            json!({"name": "group", "valueString": "Group/2"}),
369        ]));
370        assert_eq!(p.group, vec!["Group/1".to_string(), "Group/2".to_string()]);
371    }
372
373    #[test]
374    fn inline_resources_accumulate() {
375        let p = extract_run_params_from_json(&params(vec![
376            json!({"name": "resource", "resource": {"resourceType": "Patient", "id": "1"}}),
377            json!({"name": "resource", "resource": {"resourceType": "Patient", "id": "2"}}),
378        ]));
379        assert_eq!(p.inline_resources.len(), 2);
380    }
381
382    #[test]
383    fn view_resource_extracted() {
384        let p = extract_run_params_from_json(&params(vec![json!({
385            "name": "viewResource",
386            "resource": {"resourceType": "ViewDefinition"}
387        })]));
388        assert!(p.view_resource.is_some());
389    }
390
391    #[test]
392    fn view_reference_string_or_reference() {
393        let p = extract_run_params_from_json(&params(vec![json!({
394            "name": "viewReference",
395            "valueReference": {"reference": "ViewDefinition/x"}
396        })]));
397        assert_eq!(p.view_reference.as_deref(), Some("ViewDefinition/x"));
398        let p = extract_run_params_from_json(&params(vec![json!({
399            "name": "viewReference",
400            "valueString": "ViewDefinition/y"
401        })]));
402        assert_eq!(p.view_reference.as_deref(), Some("ViewDefinition/y"));
403    }
404
405    #[test]
406    fn typed_serde_name_shape_accepted() {
407        // Mirrors how the FHIR typed `RunParameters` serialises (`name.value`).
408        let p = extract_run_params_from_json(&params(vec![json!({
409            "name": {"value": "_format"},
410            "valueCode": "json"
411        })]));
412        assert_eq!(p.format.as_deref(), Some("json"));
413    }
414
415    #[test]
416    fn parquet_options_extracted() {
417        let p = extract_run_params_from_json(&params(vec![
418            json!({"name": "maxFileSize", "valueInteger": 500}),
419            json!({"name": "rowGroupSize", "valueInteger": 128}),
420            json!({"name": "pageSize", "valueInteger": 1024}),
421            json!({"name": "compression", "valueCode": "snappy"}),
422        ]));
423        assert_eq!(p.max_file_size, Some(500));
424        assert_eq!(p.row_group_size, Some(128));
425        assert_eq!(p.page_size, Some(1024));
426        assert_eq!(p.compression.as_deref(), Some("snappy"));
427    }
428
429    #[test]
430    fn split_csv_refs_trims_and_drops_empty() {
431        assert_eq!(split_csv_refs(None), Vec::<String>::new());
432        assert_eq!(split_csv_refs(Some("")), Vec::<String>::new());
433        assert_eq!(
434            split_csv_refs(Some("Group/a, Group/b ,,Group/c")),
435            vec![
436                "Group/a".to_string(),
437                "Group/b".to_string(),
438                "Group/c".to_string()
439            ]
440        );
441    }
442
443    #[test]
444    fn unknown_param_names_ignored() {
445        let p = extract_run_params_from_json(&params(vec![
446            json!({"name": "_format", "valueCode": "json"}),
447            json!({"name": "unknownParam", "valueString": "ignored"}),
448        ]));
449        assert_eq!(p.format.as_deref(), Some("json"));
450    }
451}