Skip to main content

apimock_server/response/
file_response.rs

1use console::style;
2use hyper::HeaderMap;
3use serde_json::{Map, Value};
4use tokio::task;
5
6use std::{collections::HashMap, fs, path::Path};
7
8use crate::{
9    constant::CSV_RECORDS_DEFAULT_KEY,
10    json_path_util::resolve_with_json_compatible_extensions,
11    response::{
12        confine::confine, error_response::not_found_response, json_response::json_response,
13    },
14    response_handler::ResponseHandler,
15    types::BoxBody,
16};
17
18use super::{
19    error_response::internal_server_error_response,
20    text_response::text_response,
21    util::{
22        binary_content_type, file_extension, json_value_with_jsonpath_key, text_file_content_type,
23    },
24};
25
26pub struct FileResponse {
27    file_path: String,
28    csv_records_key: Option<String>,
29    text_content: Option<String>,
30    binary_content: Option<Vec<u8>>,
31    custom_headers: Option<HashMap<String, Option<String>>>,
32    request_headers: HeaderMap,
33    /// The directory `file_path` must resolve inside, already
34    /// canonicalised by the caller. `None` means it couldn't be (the
35    /// directory doesn't exist) — every candidate is then refused,
36    /// never served unchecked.
37    confine_to: Option<std::path::PathBuf>,
38}
39
40impl FileResponse {
41    /// create instance
42    pub fn new(
43        file_path: &str,
44        custom_headers: Option<&HashMap<String, Option<String>>>,
45        request_headers: &HeaderMap,
46        confine_to: Option<&Path>,
47    ) -> Self {
48        FileResponse {
49            file_path: file_path.to_owned(),
50            csv_records_key: None,
51            text_content: None,
52            binary_content: None,
53            custom_headers: custom_headers.cloned(),
54            request_headers: request_headers.clone(),
55            confine_to: confine_to.map(Path::to_path_buf),
56        }
57    }
58
59    /// create instance
60    pub fn new_with_csv_records_jsonpath(
61        file_path: &str,
62        custom_headers: Option<&HashMap<String, Option<String>>>,
63        csv_records_key: Option<String>,
64        request_headers: &HeaderMap,
65        confine_to: Option<&Path>,
66    ) -> Self {
67        let mut ret = FileResponse::new(file_path, custom_headers, request_headers, confine_to);
68        ret.csv_records_key = csv_records_key;
69        ret
70    }
71
72    /// response from file path
73    pub async fn file_content_response(
74        &mut self,
75    ) -> Result<hyper::Response<BoxBody>, hyper::http::Error> {
76        let file_path = match resolve_with_json_compatible_extensions(self.file_path.as_str()) {
77            Some(x) => x,
78            None => {
79                log::warn!(
80                    "{}:\n{} (missing or a directory)",
81                    style("file not found").red(),
82                    self.file_path
83                );
84                return not_found_response(&self.request_headers);
85            }
86        };
87
88        // Confine the resolved candidate to the directory it was meant
89        // to come from. This runs after extension/`index.*` resolution
90        // above, so it also catches a path that only escapes at that
91        // stage (e.g. a symlinked `index.html`), not only one that
92        // arrived already outside.
93        let file_path = match confine(file_path.as_str(), self.confine_to.as_deref()) {
94            Some(canonical) => match canonical.to_str() {
95                Some(x) => x.to_owned(),
96                None => {
97                    log::error!(
98                        "{} to get str from canonicalized file path:\n{}",
99                        style("failed").red(),
100                        file_path
101                    );
102                    return not_found_response(&self.request_headers);
103                }
104            },
105            None => {
106                return not_found_response(&self.request_headers);
107            }
108        };
109        self.file_path = file_path.clone();
110
111        // read file as text file in non-blocking task
112        let file_path_to_read_text_file = file_path.clone();
113        let content =
114            task::spawn_blocking(move || fs::read_to_string(file_path_to_read_text_file)).await;
115
116        match content {
117            Ok(Ok(content)) => {
118                self.text_content = Some(content);
119                self.text_file_content_response()
120            }
121            Ok(Err(_)) => {
122                // read file as binary in non-blocking task
123                let file_path_to_read_binary = file_path.clone();
124                let content =
125                    task::spawn_blocking(move || fs::read(file_path_to_read_binary)).await;
126                match content {
127                    Ok(Ok(content)) => {
128                        self.binary_content = Some(content);
129                        self.binary_content_type_response()
130                    }
131                    Ok(Err(err)) => internal_server_error_response(
132                        &format!("{}: failed to read file - {}", self.file_path, err),
133                        &self.request_headers,
134                    ),
135                    Err(err) => internal_server_error_response(
136                        &format!("{}: async task failed - {}", self.file_path, err),
137                        &self.request_headers,
138                    ),
139                }
140            }
141            Err(err) => internal_server_error_response(
142                &format!("{}: async task failed - {}", self.file_path, err),
143                &self.request_headers,
144            ),
145        }
146    }
147
148    /// text file response
149    ///
150    /// `self.custom_headers` is threaded through here the same way
151    /// `json_file_content_response`/`csv_file_content_response` already
152    /// do below - this branch previously hardcoded `None`, silently
153    /// dropping every custom header on a plain-text `file_path` response
154    /// (RFC 045 Defect 1, extended: this contradicted the RFC's own
155    /// "`file_path` | honoured" claim, which held only for the
156    /// json/json5/csv sub-cases).
157    fn text_file_content_response(&self) -> Result<hyper::Response<BoxBody>, hyper::http::Error> {
158        match file_extension(self.file_path.as_str()) {
159            Some(ext) => match ext.as_str() {
160                "json" | "json5" => self.json_file_content_response(),
161                "csv" => self.csv_file_content_response(),
162                _ => text_response(
163                    self.text_content.clone().unwrap_or_default().as_str(),
164                    Some(text_file_content_type(ext).as_str()),
165                    self.custom_headers.as_ref(),
166                    &self.request_headers,
167                ),
168            },
169            None => text_response(
170                self.text_content.clone().unwrap_or_default().as_str(),
171                None,
172                self.custom_headers.as_ref(),
173                &self.request_headers,
174            ),
175        }
176    }
177
178    /// json file response
179    fn json_file_content_response(&self) -> Result<hyper::Response<BoxBody>, hyper::http::Error> {
180        let json_str = self.text_content.clone().unwrap_or_default();
181        json_response(
182            json_str.as_str(),
183            self.custom_headers.as_ref(),
184            &self.request_headers,
185            self.file_path.as_str(),
186        )
187    }
188
189    /// csv file response
190    fn csv_file_content_response(&self) -> Result<hyper::Response<BoxBody>, hyper::http::Error> {
191        let text_content = self.text_content.clone().unwrap_or_default();
192        let mut rdr = csv::ReaderBuilder::new()
193            .has_headers(true)
194            .from_reader(text_content.as_bytes());
195
196        let csv_headers = if let Ok(csv_headers) = rdr.headers() {
197            csv_headers.clone()
198        } else {
199            return internal_server_error_response(
200                &format!("{}: failed to analyze csv headers", self.file_path.as_str()),
201                &self.request_headers,
202            );
203        };
204
205        let rows = rdr
206            .records()
207            .map(|result| {
208                let record = result?;
209                let obj = csv_headers
210                    .iter()
211                    .zip(record.iter())
212                    .map(|(k, v)| (k.to_string(), Value::String(v.to_string())))
213                    .collect::<Map<_, _>>();
214                Ok(Value::Object(obj))
215            })
216            .collect::<Result<Vec<Value>, csv::Error>>();
217
218        match rows {
219            Ok(rows) => {
220                let jsonpath_key = if let Some(csv_records_key) = self.csv_records_key.as_ref() {
221                    csv_records_key.as_str()
222                } else {
223                    CSV_RECORDS_DEFAULT_KEY
224                };
225                let json_value = json_value_with_jsonpath_key(jsonpath_key, Value::from(rows));
226
227                let body = serde_json::to_string(&json_value);
228                match body {
229                    Ok(body) => json_response(
230                        body.as_str(),
231                        self.custom_headers.as_ref(),
232                        &self.request_headers,
233                        self.file_path.as_str(),
234                    ),
235                    Err(err) => internal_server_error_response(
236                        &format!(
237                            "{}: failed to convert csv records to json response - {}",
238                            self.file_path.as_str(),
239                            err
240                        ),
241                        &self.request_headers,
242                    ),
243                }
244            }
245            Err(err) => internal_server_error_response(
246                &format!(
247                    "{}: failed to analyze csv records - {}",
248                    self.file_path.as_str(),
249                    err
250                ),
251                &self.request_headers,
252            ),
253        }
254    }
255
256    /// binary file response
257    fn binary_content_type_response(&self) -> Result<hyper::Response<BoxBody>, hyper::http::Error> {
258        let mut response_handler = ResponseHandler::default();
259
260        if let Some(custom_headers) = self.custom_headers.clone() {
261            response_handler = response_handler.with_headers(custom_headers);
262        }
263
264        let content = self.binary_content.clone().unwrap_or_default().to_owned();
265        let content_type = binary_content_type(self.file_path.as_str());
266        response_handler
267            .with_binary_body(content, Some(content_type))
268            .into_response(&self.request_headers)
269    }
270}