Skip to main content

ravenrs_report_server/
http.rs

1//! The HTTP API. Every route but `/health` needs the API token.
2
3use std::collections::BTreeMap;
4use std::io;
5use std::sync::Arc;
6
7use axum::body::Bytes;
8use axum::extract::{DefaultBodyLimit, Path, Query, Request, State};
9use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
10use axum::middleware::{self, Next};
11use axum::response::{IntoResponse, Response};
12use axum::routing::{get, post};
13use axum::{Json, Router};
14use jiff::Timestamp;
15use jiff::tz::TimeZone;
16use ravenrs_report::Report;
17use serde::Deserialize;
18use serde_json::{Value as JsonValue, json};
19
20use crate::config::Config;
21use crate::cron::Schedule;
22use crate::job::{Delivery, Format, Job, ParameterValue};
23use crate::render::{local_date_time, parameter_values, render, resolve_data};
24use crate::scheduler::Runner;
25use crate::store::{JobSummary, Store, valid_name};
26
27/// The default and largest number of runs a listing returns.
28const RUNS_DEFAULT: usize = 50;
29const RUNS_MAX: usize = 500;
30
31/// What the handlers share.
32#[derive(Clone)]
33pub struct AppState {
34    store: Store,
35    runner: Runner,
36    config: Arc<Config>,
37    token: Arc<str>,
38}
39
40impl std::fmt::Debug for AppState {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        f.debug_struct("AppState").field("store", &self.store).finish_non_exhaustive()
43    }
44}
45
46impl AppState {
47    /// The state for [`router`].
48    pub fn new(store: Store, runner: Runner, config: Arc<Config>, token: String) -> Self {
49        Self { store, runner, config, token: token.into() }
50    }
51}
52
53/// An error answered to the client. Internal details go to the log, not the answer.
54enum ApiError {
55    BadRequest(String),
56    NotFound,
57    Conflict(String),
58    Internal(String),
59}
60
61impl From<io::Error> for ApiError {
62    fn from(e: io::Error) -> Self {
63        if e.kind() == io::ErrorKind::InvalidInput {
64            Self::BadRequest(e.to_string())
65        } else {
66            Self::Internal(e.to_string())
67        }
68    }
69}
70
71impl IntoResponse for ApiError {
72    fn into_response(self) -> Response {
73        let (status, message) = match self {
74            Self::BadRequest(message) => (StatusCode::BAD_REQUEST, message),
75            Self::NotFound => (StatusCode::NOT_FOUND, "not found".to_owned()),
76            Self::Conflict(message) => (StatusCode::CONFLICT, message),
77            Self::Internal(detail) => {
78                tracing::error!(error = %detail, "request failed");
79                (StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_owned())
80            }
81        };
82        (status, Json(json!({ "error": message }))).into_response()
83    }
84}
85
86/// The API's routes.
87pub fn router(state: AppState) -> Router {
88    let limit = state.config.max_body_megabytes.saturating_mul(1024 * 1024);
89    let protected = Router::new()
90        .route("/render", post(render_now))
91        .route("/layouts", get(list_layouts))
92        .route("/layouts/{name}", get(get_layout).put(put_layout).delete(delete_layout))
93        .route("/layouts/{name}/versions", get(layout_versions))
94        .route("/jobs", get(list_jobs))
95        .route("/jobs/{id}", get(get_job).put(put_job).delete(delete_job))
96        .route("/jobs/{id}/run", post(run_job))
97        .route("/jobs/{id}/runs", get(list_runs))
98        .route("/jobs/{id}/runs/{run}", get(get_run))
99        .route("/jobs/{id}/runs/{run}/documents/{file}", get(get_document))
100        .route_layer(middleware::from_fn_with_state(state.clone(), require_token));
101    Router::new()
102        .route("/health", get(|| async { Json(json!({ "status": "ok" })) }))
103        .merge(protected)
104        .layer(DefaultBodyLimit::max(limit))
105        .with_state(state)
106}
107
108/// Compares without stopping at the first difference, so the time an answer
109/// takes says nothing about how much of a guessed token was right.
110fn same_secret(a: &[u8], b: &[u8]) -> bool {
111    if a.len() != b.len() {
112        return false;
113    }
114    a.iter().zip(b).fold(0_u8, |difference, (x, y)| difference | (x ^ y)) == 0
115}
116
117async fn require_token(
118    State(state): State<AppState>,
119    headers: HeaderMap,
120    request: Request,
121    next: Next,
122) -> Response {
123    let presented = headers
124        .get(header::AUTHORIZATION)
125        .and_then(|value| value.to_str().ok())
126        .and_then(|value| value.strip_prefix("Bearer "));
127    if presented.is_some_and(|token| same_secret(token.as_bytes(), state.token.as_bytes())) {
128        return next.run(request).await;
129    }
130    tracing::warn!(path = %request.uri().path(), "request without a valid token");
131    (
132        StatusCode::UNAUTHORIZED,
133        [(header::WWW_AUTHENTICATE, "Bearer")],
134        Json(json!({ "error": "a valid API token is required" })),
135    )
136        .into_response()
137}
138
139fn parse_report(text: &str) -> Result<Report, ApiError> {
140    Report::from_json(text).map_err(|e| ApiError::BadRequest(format!("not a valid layout: {e}")))
141}
142
143async fn list_layouts(State(state): State<AppState>) -> Result<Json<Vec<String>>, ApiError> {
144    Ok(Json(state.store.layouts()?))
145}
146
147async fn put_layout(
148    State(state): State<AppState>,
149    Path(name): Path<String>,
150    body: String,
151) -> Result<Json<JsonValue>, ApiError> {
152    parse_report(&body)?;
153    let version = state.store.save_layout(&name, &body)?;
154    Ok(Json(json!({ "name": name, "version": version })))
155}
156
157#[derive(Deserialize)]
158struct VersionQuery {
159    version: Option<u32>,
160}
161
162async fn get_layout(
163    State(state): State<AppState>,
164    Path(name): Path<String>,
165    Query(query): Query<VersionQuery>,
166) -> Result<Response, ApiError> {
167    let (version, text) = state.store.layout(&name, query.version)?.ok_or(ApiError::NotFound)?;
168    Ok((
169        [(header::CONTENT_TYPE, "application/json".to_owned()), (header::ETAG, format!("\"{version}\""))],
170        text,
171    )
172        .into_response())
173}
174
175async fn layout_versions(
176    State(state): State<AppState>,
177    Path(name): Path<String>,
178) -> Result<Response, ApiError> {
179    let versions = state.store.layout_versions(&name)?;
180    if versions.is_empty() {
181        return Err(ApiError::NotFound);
182    }
183    Ok(Json(versions).into_response())
184}
185
186async fn delete_layout(
187    State(state): State<AppState>,
188    Path(name): Path<String>,
189) -> Result<StatusCode, ApiError> {
190    for id in state.store.job_ids()? {
191        if state.store.job(&id)?.is_some_and(|job| job.layout == name) {
192            return Err(ApiError::Conflict(format!("the job '{id}' uses this layout")));
193        }
194    }
195    if state.store.delete_layout(&name)? { Ok(StatusCode::NO_CONTENT) } else { Err(ApiError::NotFound) }
196}
197
198/// A request to render now.
199#[derive(Deserialize)]
200#[serde(rename_all = "camelCase", deny_unknown_fields)]
201struct RenderRequest {
202    /// A stored layout's name.
203    name: Option<String>,
204    /// A version of it; the latest by default.
205    version: Option<u32>,
206    /// A layout sent with the request, instead of a stored one.
207    layout: Option<JsonValue>,
208    /// Data sent with the request, instead of the layout's data source.
209    data: Option<JsonValue>,
210    #[serde(default)]
211    parameters: BTreeMap<String, ParameterValue>,
212    format: Format,
213    /// The time zone of `Today()`.
214    timezone: Option<String>,
215}
216
217async fn render_now(State(state): State<AppState>, body: Bytes) -> Result<Response, ApiError> {
218    let request: RenderRequest =
219        serde_json::from_slice(&body).map_err(|e| ApiError::BadRequest(format!("bad request: {e}")))?;
220    let report = match (&request.name, &request.layout) {
221        (Some(name), None) => {
222            let (_, text) = state.store.layout(name, request.version)?.ok_or(ApiError::NotFound)?;
223            parse_report(&text)?
224        }
225        (None, Some(layout)) => serde_json::from_value(layout.clone())
226            .map_err(|e| ApiError::BadRequest(format!("not a valid layout: {e}")))?,
227        _ => return Err(ApiError::BadRequest("send either 'name' or 'layout'".to_owned())),
228    };
229    let zone = TimeZone::get(request.timezone.as_deref().unwrap_or("UTC"))
230        .map_err(|_| ApiError::BadRequest("unknown time zone".to_owned()))?;
231    let local = Timestamp::now().to_zoned(zone);
232    let now = local_date_time(&local).ok_or_else(|| ApiError::Internal("date out of range".to_owned()))?;
233    let date = local.strftime("%Y-%m-%d").to_string();
234    let parameters = parameter_values(&request.parameters, now).map_err(ApiError::BadRequest)?;
235    let format = request.format;
236    let config = state.config.clone();
237    let permit =
238        state.runner.permits().acquire_owned().await.map_err(|e| ApiError::Internal(e.to_string()))?;
239    let rendered = tokio::task::spawn_blocking(move || {
240        let _permit = permit;
241        let data = match request.data {
242            Some(data) => data,
243            None => resolve_data(&report, &parameters, &config)?,
244        };
245        render(&report, &data, parameters, now, &[format], &date)
246    })
247    .await
248    .map_err(|e| ApiError::Internal(e.to_string()))?
249    .map_err(ApiError::BadRequest)?;
250    let warnings = rendered.warnings.len();
251    let file = rendered.files.into_iter().next().ok_or_else(|| ApiError::Internal("no file".to_owned()))?;
252    Ok(file_response(&file.name, file.mime, file.bytes, Some(warnings)))
253}
254
255/// A file as a download. The name is sent in both of the header's forms, so
256/// neither a quote nor an accent in a title can break it.
257fn file_response(name: &str, mime: &str, bytes: Vec<u8>, warnings: Option<usize>) -> Response {
258    let ascii: String =
259        name.chars().map(|c| if c.is_ascii_alphanumeric() || " .-_".contains(c) { c } else { '_' }).collect();
260    let encoded: String = name
261        .bytes()
262        .map(|b| {
263            if b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_') {
264                (b as char).to_string()
265            } else {
266                format!("%{b:02X}")
267            }
268        })
269        .collect();
270    let disposition = format!("attachment; filename=\"{ascii}\"; filename*=UTF-8''{encoded}");
271    let mut response = bytes.into_response();
272    let headers = response.headers_mut();
273    if let Ok(value) = HeaderValue::from_str(mime) {
274        headers.insert(header::CONTENT_TYPE, value);
275    }
276    if let Ok(value) = HeaderValue::from_str(&disposition) {
277        headers.insert(header::CONTENT_DISPOSITION, value);
278    }
279    headers.insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
280    if let Some(warnings) = warnings {
281        headers.insert("x-report-warnings", HeaderValue::from(warnings));
282    }
283    response
284}
285
286async fn list_jobs(State(state): State<AppState>) -> Result<Json<Vec<JobSummary>>, ApiError> {
287    let mut jobs = Vec::new();
288    for id in state.store.job_ids()? {
289        if let Some(job) = state.store.job(&id)? {
290            let next_run = state.store.job_state(&id)?.next_run;
291            jobs.push(JobSummary { id, job, next_run });
292        }
293    }
294    Ok(Json(jobs))
295}
296
297async fn get_job(
298    State(state): State<AppState>,
299    Path(id): Path<String>,
300) -> Result<Json<JobSummary>, ApiError> {
301    let job = state.store.job(&id)?.ok_or(ApiError::NotFound)?;
302    let next_run = state.store.job_state(&id)?.next_run;
303    Ok(Json(JobSummary { id, job, next_run }))
304}
305
306async fn put_job(
307    State(state): State<AppState>,
308    Path(id): Path<String>,
309    body: Bytes,
310) -> Result<Json<JsonValue>, ApiError> {
311    if !valid_name(&id) {
312        return Err(ApiError::BadRequest(format!("'{id}' is not a valid name")));
313    }
314    let job: Job =
315        serde_json::from_slice(&body).map_err(|e| ApiError::BadRequest(format!("bad job: {e}")))?;
316    job.validate().map_err(ApiError::BadRequest)?;
317    if state.store.layout(&job.layout, None)?.is_none() {
318        return Err(ApiError::BadRequest(format!("the layout '{}' does not exist", job.layout)));
319    }
320    for delivery in &job.deliveries {
321        match delivery {
322            Delivery::Email { .. } if state.config.smtp.is_none() => {
323                return Err(ApiError::BadRequest("the server has no mail server configured".to_owned()));
324            }
325            Delivery::Folder { .. } if state.config.output_dir.is_none() => {
326                return Err(ApiError::BadRequest("the server has no outputDir configured".to_owned()));
327            }
328            _ => {}
329        }
330    }
331    state.store.save_job(&id, &job)?;
332    let schedule = Schedule::parse(&job.schedule).map_err(ApiError::BadRequest)?;
333    let zone = TimeZone::get(&job.timezone).map_err(|e| ApiError::BadRequest(e.to_string()))?;
334    let next_run = schedule.next_after(&Timestamp::now().to_zoned(zone)).map(|z| z.timestamp());
335    state.store.save_job_state(&id, &crate::store::JobState { next_run })?;
336    Ok(Json(json!({ "id": id, "nextRun": next_run })))
337}
338
339async fn delete_job(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, ApiError> {
340    if state.store.delete_job(&id)? { Ok(StatusCode::NO_CONTENT) } else { Err(ApiError::NotFound) }
341}
342
343async fn run_job(State(state): State<AppState>, Path(id): Path<String>) -> Result<Response, ApiError> {
344    let job = state.store.job(&id)?.ok_or(ApiError::NotFound)?;
345    let runner = state.runner.clone();
346    let job_id = id.clone();
347    tokio::spawn(async move { runner.run_now(&job_id, &job).await });
348    Ok((StatusCode::ACCEPTED, Json(json!({ "job": id, "status": "started" }))).into_response())
349}
350
351#[derive(Deserialize)]
352struct RunsQuery {
353    limit: Option<usize>,
354}
355
356async fn list_runs(
357    State(state): State<AppState>,
358    Path(id): Path<String>,
359    Query(query): Query<RunsQuery>,
360) -> Result<Response, ApiError> {
361    if state.store.job(&id)?.is_none() {
362        return Err(ApiError::NotFound);
363    }
364    let limit = query.limit.unwrap_or(RUNS_DEFAULT).min(RUNS_MAX);
365    Ok(Json(state.store.runs(&id, limit)?).into_response())
366}
367
368async fn get_run(
369    State(state): State<AppState>,
370    Path((id, run)): Path<(String, String)>,
371) -> Result<Response, ApiError> {
372    let record = state.store.run(&id, &run)?.ok_or(ApiError::NotFound)?;
373    Ok(Json(record).into_response())
374}
375
376async fn get_document(
377    State(state): State<AppState>,
378    Path((id, run, file)): Path<(String, String, String)>,
379) -> Result<Response, ApiError> {
380    let bytes = state.store.document(&id, &run, &file)?.ok_or(ApiError::NotFound)?;
381    let format = match file.rsplit('.').next() {
382        Some("pdf") => Format::Pdf,
383        Some("xlsx") => Format::Xlsx,
384        _ => Format::Csv,
385    };
386    Ok(file_response(&file, format.mime(), bytes, None))
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    #[test]
394    fn secrets_compare_exactly() {
395        assert!(same_secret(b"abc", b"abc"));
396        assert!(!same_secret(b"abc", b"abd"));
397        assert!(!same_secret(b"abc", b"abcd"));
398        assert!(!same_secret(b"", b"a"));
399    }
400
401    #[test]
402    fn download_names_cannot_break_the_header() {
403        let response = file_response("Relatório \"a\"\r\nX: y.pdf", "application/pdf", vec![], None);
404        let disposition = response.headers()[header::CONTENT_DISPOSITION].to_str().unwrap_or_default();
405        assert!(disposition.contains("filename=\"Relat_rio _a___X_ y.pdf\""), "{disposition}");
406        assert!(
407            disposition.contains("filename*=UTF-8''Relat%C3%B3rio%20%22a%22%0D%0AX%3A%20y.pdf"),
408            "{disposition}"
409        );
410    }
411}