use base64::Engine as _;
use serde_json::{Value, json};
use crate::sqlquery::engine::TableSchema;
use crate::sqlquery::output::value_to_fhir_part;
use crate::sqlquery::{ColumnFhirType, SqlQueryError};
use crate::{ProcessedResult, SofError};
pub const FHIR_JSON_MIME: &str = "application/fhir+json";
pub const FHIR_XML_MIME: &str = "application/fhir+xml";
pub fn accept_has_mime(accept: Option<&str>, mime: &str) -> bool {
let Some(accept) = accept else {
return false;
};
accept
.split(',')
.map(|s| s.split(';').next().unwrap_or("").trim())
.any(|m| m.eq_ignore_ascii_case(mime))
}
pub fn accept_requires_unsupported_fhir_xml(accept: Option<&str>) -> bool {
accept_has_mime(accept, FHIR_XML_MIME) && !accept_has_mime(accept, FHIR_JSON_MIME)
}
pub fn wrap_in_binary_envelope(content_type: &str, payload: &[u8]) -> Result<Vec<u8>, SofError> {
let media_type = content_type
.split(';')
.next()
.unwrap_or(content_type)
.trim();
let binary = json!({
"resourceType": "Binary",
"contentType": media_type,
"data": base64::engine::general_purpose::STANDARD.encode(payload),
});
serde_json::to_vec(&binary).map_err(SofError::SerializationError)
}
pub fn format_view_fhir_parameters(
result: &ProcessedResult,
view_json: &Value,
) -> Result<Vec<u8>, SofError> {
let schema = TableSchema::from_view_definition(view_json);
let column_types: Vec<ColumnFhirType> = result
.columns
.iter()
.map(|name| {
schema
.columns
.iter()
.find(|c| &c.name == name)
.map(|c| c.fhir_type.clone())
.unwrap_or_else(|| ColumnFhirType::String("string".into()))
})
.collect();
let mut row_params: Vec<Value> = Vec::with_capacity(result.rows.len());
for row in &result.rows {
let mut parts: Vec<Value> = Vec::with_capacity(row.values.len());
for (i, cell) in row.values.iter().enumerate() {
let Some(value) = cell else {
continue; };
if value.is_null() {
continue;
}
let name = &result.columns[i];
let ty = &column_types[i];
match value {
Value::Array(items) => {
for item in items {
if item.is_null() {
continue;
}
parts.push(part_or_sof_error(name, item, ty)?);
}
}
other => parts.push(part_or_sof_error(name, other, ty)?),
}
}
row_params.push(json!({ "name": "row", "part": parts }));
}
let body = if row_params.is_empty() {
json!({ "resourceType": "Parameters" })
} else {
json!({ "resourceType": "Parameters", "parameter": row_params })
};
serde_json::to_vec(&body).map_err(SofError::SerializationError)
}
fn part_or_sof_error(name: &str, value: &Value, ty: &ColumnFhirType) -> Result<Value, SofError> {
value_to_fhir_part(name, value, ty).map_err(|e| match e {
SqlQueryError::UnsupportedFhirValue(col) => SofError::InvalidViewDefinition(format!(
"column '{col}' holds a complex value that cannot be represented as a \
FHIR value[x] part; the 'fhir' output format supports scalar columns only"
)),
other => SofError::InvalidViewDefinition(other.to_string()),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ProcessedRow;
use serde_json::json;
fn view(columns: Value) -> Value {
json!({
"resourceType": "ViewDefinition",
"resource": "Patient",
"select": [{ "column": columns }]
})
}
#[test]
fn typed_columns_map_to_value_x() {
let result = ProcessedResult {
columns: vec!["id".into(), "active".into(), "age".into()],
rows: vec![ProcessedRow {
values: vec![Some(json!("p1")), Some(json!(true)), Some(json!(42))],
}],
};
let v = view(json!([
{"name": "id", "path": "id", "type": "id"},
{"name": "active", "path": "active", "type": "boolean"},
{"name": "age", "path": "age", "type": "integer"}
]));
let bytes = format_view_fhir_parameters(&result, &v).unwrap();
let out: Value = serde_json::from_slice(&bytes).unwrap();
let part = &out["parameter"][0]["part"];
assert_eq!(part[0]["valueId"], json!("p1"));
assert_eq!(part[1]["valueBoolean"], json!(true));
assert_eq!(part[2]["valueInteger"], json!(42));
}
#[test]
fn untyped_column_defaults_to_string_and_null_is_omitted() {
let result = ProcessedResult {
columns: vec!["name".into(), "missing".into()],
rows: vec![ProcessedRow {
values: vec![Some(json!("Smith")), None],
}],
};
let v = view(json!([{"name": "name", "path": "name.family"}]));
let bytes = format_view_fhir_parameters(&result, &v).unwrap();
let out: Value = serde_json::from_slice(&bytes).unwrap();
let parts = out["parameter"][0]["part"].as_array().unwrap();
assert_eq!(parts.len(), 1);
assert_eq!(parts[0]["valueString"], json!("Smith"));
}
#[test]
fn collection_column_repeats_part_per_element() {
let result = ProcessedResult {
columns: vec!["given".into()],
rows: vec![ProcessedRow {
values: vec![Some(json!(["John", "Quincy"]))],
}],
};
let v = view(json!([
{"name": "given", "path": "name.given", "type": "string", "collection": true}
]));
let bytes = format_view_fhir_parameters(&result, &v).unwrap();
let out: Value = serde_json::from_slice(&bytes).unwrap();
let parts = out["parameter"][0]["part"].as_array().unwrap();
assert_eq!(parts.len(), 2);
assert_eq!(parts[0]["name"], json!("given"));
assert_eq!(parts[0]["valueString"], json!("John"));
assert_eq!(parts[1]["valueString"], json!("Quincy"));
}
#[test]
fn empty_result_omits_parameter_key() {
let result = ProcessedResult {
columns: vec!["id".into()],
rows: vec![],
};
let v = view(json!([{"name": "id", "path": "id", "type": "id"}]));
let bytes = format_view_fhir_parameters(&result, &v).unwrap();
let out: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(out["resourceType"], json!("Parameters"));
assert!(out.get("parameter").is_none());
}
#[test]
fn complex_value_errors() {
let result = ProcessedResult {
columns: vec!["name".into()],
rows: vec![ProcessedRow {
values: vec![Some(json!({"family": "Smith"}))],
}],
};
let v = view(json!([{"name": "name", "path": "name"}]));
let err = format_view_fhir_parameters(&result, &v).unwrap_err();
assert!(matches!(err, SofError::InvalidViewDefinition(_)));
}
#[test]
fn accept_matching() {
assert!(accept_has_mime(
Some("text/csv, application/fhir+json;q=0.9"),
FHIR_JSON_MIME
));
assert!(accept_has_mime(
Some("Application/FHIR+JSON"),
FHIR_JSON_MIME
));
assert!(!accept_has_mime(Some("application/json"), FHIR_JSON_MIME));
assert!(!accept_has_mime(None, FHIR_JSON_MIME));
assert!(accept_requires_unsupported_fhir_xml(Some(
"application/fhir+xml"
)));
assert!(!accept_requires_unsupported_fhir_xml(Some(
"application/fhir+xml, application/fhir+json"
)));
assert!(!accept_requires_unsupported_fhir_xml(Some("text/csv")));
}
#[test]
fn binary_envelope_round_trips() {
let bytes = wrap_in_binary_envelope("text/csv", b"a,b\n1,2\n").unwrap();
let v: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["resourceType"], json!("Binary"));
assert_eq!(v["contentType"], json!("text/csv"));
let decoded = base64::engine::general_purpose::STANDARD
.decode(v["data"].as_str().unwrap())
.unwrap();
assert_eq!(decoded, b"a,b\n1,2\n");
}
}