Skip to main content

helios_sof/
params.rs

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