1use std::collections::{BTreeMap, HashMap};
4use std::io::Read as _;
5use std::time::Duration;
6
7use jiff::Zoned;
8use ravenrs_report::{Criteria, DataSourceKind, DateTime, RenderOptions, Report, ReportEngine, Value};
9use serde_json::{Value as Json, json};
10
11use crate::config::{Config, DataHost};
12use crate::job::{Format, ParameterValue};
13
14const MAX_DATA_BYTES: u64 = 64 * 1024 * 1024;
16const DATA_TIMEOUT: Duration = Duration::from_secs(60);
18
19pub use ravenrs_report_export::ExportedFile as OutputFile;
21
22#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct Rendered {
25 pub title: String,
27 pub files: Vec<OutputFile>,
29 pub rows: usize,
31 pub warnings: Vec<String>,
33}
34
35fn date_text(d: DateTime) -> String {
37 let (year, month, day) = d.ymd();
38 let (hour, minute, second) = d.hms();
39 format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}")
40}
41
42pub fn local_date_time(now: &Zoned) -> Option<DateTime> {
44 let mut date = DateTime::from_ymd(i32::from(now.year()), now.month() as u32, now.day() as u32)?;
45 date.seconds = now.hour() as u32 * 3600 + now.minute() as u32 * 60 + now.second() as u32;
46 Some(date)
47}
48
49pub fn parameter_values(
51 parameters: &BTreeMap<String, ParameterValue>,
52 now: DateTime,
53) -> Result<HashMap<String, Json>, String> {
54 let env = ravenrs_report::criteria::Environment::new().at(now);
55 let mut values = HashMap::new();
56 for (name, value) in parameters {
57 let json = match value {
58 ParameterValue::Fixed(json) => json.clone(),
59 ParameterValue::Expression { expression } => {
60 let criteria = Criteria::parse(expression).map_err(|e| format!("parameter {name}: {e}"))?;
61 let scope = ravenrs_report::criteria::Scope::<Json>::new(&env);
62 match scope.evaluate(&criteria).map_err(|e| format!("parameter {name}: {e}"))? {
63 Value::Null => Json::Null,
64 Value::Bool(b) => json!(b),
65 Value::Number(n) => json!(n),
66 Value::Date(d) => json!(date_text(d)),
67 other => json!(other.to_text()),
68 }
69 }
70 };
71 values.insert(name.clone(), json);
72 }
73 Ok(values)
74}
75
76pub fn resolve_data(
78 report: &Report,
79 parameters: &HashMap<String, Json>,
80 config: &Config,
81) -> Result<Json, String> {
82 let source = match &report.data_source {
83 Some(name) => report
84 .data_sources
85 .iter()
86 .find(|s| &s.name == name)
87 .ok_or_else(|| format!("the data source '{name}' is not in the layout"))?,
88 None => match report.data_sources.first() {
89 Some(source) => source,
90 None => return Ok(Json::Null),
91 },
92 };
93 match &source.kind {
94 DataSourceKind::Json { data } => Ok(data.clone()),
95 DataSourceKind::Url { url, headers } => fetch(url, headers, parameters, &config.data_hosts),
96 DataSourceKind::Sql { connection, .. } => {
97 Err(format!("SQL data sources are not supported yet (connection '{connection}')"))
98 }
99 _ => Err("this kind of data source is not supported".to_owned()),
100 }
101}
102
103fn encode_component(text: &str) -> String {
106 let mut out = String::new();
107 for byte in text.bytes() {
108 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
109 out.push(byte as char);
110 } else {
111 out.push_str(&format!("%{byte:02X}"));
112 }
113 }
114 out
115}
116
117fn fill_url(url: &str, parameters: &HashMap<String, Json>) -> String {
119 let mut out = url.to_owned();
120 for (name, value) in parameters {
121 let text = match value {
122 Json::String(s) => s.clone(),
123 other => other.to_string(),
124 };
125 out = out.replace(&format!("{{?{name}}}"), &encode_component(&text));
126 }
127 out
128}
129
130fn allowed_host<'c>(url: &str, hosts: &'c [DataHost]) -> Result<&'c DataHost, String> {
133 let uri: ureq::http::Uri = url.parse().map_err(|_| format!("'{url}' is not a valid URL"))?;
134 if !matches!(uri.scheme_str(), Some("http" | "https")) {
135 return Err(format!("'{url}' must use http or https"));
136 }
137 let authority = uri.authority().map_or("", |a| a.as_str());
138 if authority.contains('@') {
139 return Err(format!("'{url}' must not contain credentials"));
140 }
141 let host = uri.host().unwrap_or_default();
142 hosts
143 .iter()
144 .find(|allowed| allowed.host.eq_ignore_ascii_case(host))
145 .ok_or_else(|| format!("the host '{host}' is not in the server's dataHosts"))
146}
147
148fn fetch(
149 url: &str,
150 headers: &BTreeMap<String, String>,
151 parameters: &HashMap<String, Json>,
152 hosts: &[DataHost],
153) -> Result<Json, String> {
154 let url = fill_url(url, parameters);
155 let host = allowed_host(&url, hosts)?;
156 let agent: ureq::Agent = ureq::Agent::config_builder()
158 .max_redirects(0)
159 .timeout_global(Some(DATA_TIMEOUT))
160 .http_status_as_error(false)
161 .build()
162 .into();
163 let mut request = agent.get(&url).header("Accept", "application/json");
164 for (name, value) in headers {
165 request = request.header(name, value);
166 }
167 for (name, variable) in &host.headers_env {
168 let value =
169 std::env::var(variable).map_err(|_| format!("the environment variable {variable} is not set"))?;
170 request = request.header(name, &value);
171 }
172 let mut response = request.call().map_err(|e| format!("could not read {}: {e}", host.host))?;
173 let status = response.status();
174 if !status.is_success() {
175 return Err(format!("{} answered {status}", host.host));
176 }
177 let mut bytes = Vec::new();
178 response
179 .body_mut()
180 .as_reader()
181 .take(MAX_DATA_BYTES + 1)
182 .read_to_end(&mut bytes)
183 .map_err(|e| format!("could not read {}: {e}", host.host))?;
184 if bytes.len() as u64 > MAX_DATA_BYTES {
185 return Err(format!("{} returned more than {} MB", host.host, MAX_DATA_BYTES / (1024 * 1024)));
186 }
187 serde_json::from_slice(&bytes).map_err(|e| format!("{} did not return JSON: {e}", host.host))
188}
189
190pub fn render(
192 report: &Report,
193 data: &Json,
194 parameters: HashMap<String, Json>,
195 now: DateTime,
196 formats: &[Format],
197 date_label: &str,
198) -> Result<Rendered, String> {
199 let options = RenderOptions { parameters, now: Some(now) };
200 let mut engine = ReportEngine::new();
201 let rows = engine.count_rows(report, data, &options);
202 let document = engine.render_with(report, data, &options);
203 let files = formats
204 .iter()
205 .map(|format| ravenrs_report_export::export(&document, *format, Some(date_label)))
206 .collect::<Result<Vec<_>, _>>()?;
207 Ok(Rendered { title: document.title, files, rows, warnings: document.errors })
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 fn hosts() -> Vec<DataHost> {
215 vec![DataHost { host: "api.example.com".to_owned(), headers_env: BTreeMap::new() }]
216 }
217
218 #[test]
219 fn only_configured_hosts_can_be_reached() {
220 let hosts = hosts();
221 assert!(allowed_host("https://api.example.com/x", &hosts).is_ok());
222 assert!(allowed_host("https://API.example.com/x", &hosts).is_ok());
223 for bad in [
224 "https://evil.example.com/x",
225 "http://169.254.169.254/latest/meta-data",
226 "http://localhost/",
227 "file:///etc/passwd",
228 "https://api.example.com@evil.com/",
229 "https://evil.com/api.example.com",
230 "api.example.com/x",
231 ] {
232 assert!(allowed_host(bad, &hosts).is_err(), "{bad}");
233 }
234 }
235
236 #[test]
237 fn parameter_values_cannot_change_the_host() {
238 let values =
239 HashMap::from([("Id".to_owned(), json!("x@evil.com/../a b")), ("N".to_owned(), json!(5))]);
240 let url = fill_url("https://{?Id}/items?n={?N}&id={?Id}", &values);
241 assert_eq!(url, "https://x%40evil.com%2F..%2Fa%20b/items?n=5&id=x%40evil.com%2F..%2Fa%20b");
242 assert!(allowed_host(&url, &hosts()).is_err());
243 }
244
245 #[test]
246 fn expressions_compute_values_at_the_run_time() -> Result<(), String> {
247 let now = DateTime::from_ymd(2026, 3, 31).ok_or("date")?;
248 let parameters = BTreeMap::from([
249 ("Fixed".to_owned(), ParameterValue::Fixed(json!("Sul"))),
250 (
251 "From".to_owned(),
252 ParameterValue::Expression { expression: "AddMonths(Today(), -1)".to_owned() },
253 ),
254 ("Sum".to_owned(), ParameterValue::Expression { expression: "1 + 2".to_owned() }),
255 ]);
256 let values = parameter_values(¶meters, now)?;
257 assert_eq!(values["Fixed"], json!("Sul"));
258 assert_eq!(values["From"], json!("2026-02-28T00:00:00"));
259 assert_eq!(values["Sum"], json!(3.0));
260 Ok(())
261 }
262}