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)) => {
132                        log::error!("failed to read file ({}): {}", self.file_path, err);
133                        internal_server_error_response(
134                            "failed to read response file",
135                            &self.request_headers,
136                        )
137                    }
138                    Err(err) => {
139                        log::error!("async task failed ({}): {}", self.file_path, err);
140                        internal_server_error_response(
141                            "failed to read response file",
142                            &self.request_headers,
143                        )
144                    }
145                }
146            }
147            Err(err) => {
148                log::error!("async task failed ({}): {}", self.file_path, err);
149                internal_server_error_response(
150                    "failed to read response file",
151                    &self.request_headers,
152                )
153            }
154        }
155    }
156
157    /// text file response
158    ///
159    /// `self.custom_headers` is threaded through here the same way
160    /// `json_file_content_response`/`csv_file_content_response` already
161    /// do below - this branch previously hardcoded `None`, silently
162    /// dropping every custom header on a plain-text `file_path` response
163    /// (RFC 045 Defect 1, extended: this contradicted the RFC's own
164    /// "`file_path` | honoured" claim, which held only for the
165    /// json/json5/csv sub-cases).
166    fn text_file_content_response(&self) -> Result<hyper::Response<BoxBody>, hyper::http::Error> {
167        match file_extension(self.file_path.as_str()) {
168            Some(ext) => match ext.as_str() {
169                "json" | "json5" => self.json_file_content_response(),
170                "csv" => self.csv_file_content_response(),
171                _ => text_response(
172                    self.text_content.clone().unwrap_or_default().as_str(),
173                    Some(text_file_content_type(ext).as_str()),
174                    self.custom_headers.as_ref(),
175                    &self.request_headers,
176                ),
177            },
178            None => text_response(
179                self.text_content.clone().unwrap_or_default().as_str(),
180                None,
181                self.custom_headers.as_ref(),
182                &self.request_headers,
183            ),
184        }
185    }
186
187    /// json file response
188    fn json_file_content_response(&self) -> Result<hyper::Response<BoxBody>, hyper::http::Error> {
189        let json_str = self.text_content.clone().unwrap_or_default();
190        json_response(
191            json_str.as_str(),
192            None,
193            self.custom_headers.as_ref(),
194            &self.request_headers,
195            Some(self.file_path.as_str()),
196        )
197    }
198
199    /// csv file response
200    fn csv_file_content_response(&self) -> Result<hyper::Response<BoxBody>, hyper::http::Error> {
201        let text_content = self.text_content.clone().unwrap_or_default();
202        let mut rdr = csv::ReaderBuilder::new()
203            .has_headers(true)
204            .from_reader(text_content.as_bytes());
205
206        let csv_headers = if let Ok(csv_headers) = rdr.headers() {
207            csv_headers.clone()
208        } else {
209            log::error!(
210                "failed to analyze csv headers ({})",
211                self.file_path.as_str()
212            );
213            return internal_server_error_response(
214                "failed to analyze csv headers",
215                &self.request_headers,
216            );
217        };
218
219        let rows = rdr
220            .records()
221            .map(|result| {
222                let record = result?;
223                let obj = csv_headers
224                    .iter()
225                    .zip(record.iter())
226                    .map(|(k, v)| (k.to_string(), Value::String(v.to_string())))
227                    .collect::<Map<_, _>>();
228                Ok(Value::Object(obj))
229            })
230            .collect::<Result<Vec<Value>, csv::Error>>();
231
232        match rows {
233            Ok(rows) => {
234                let jsonpath_key = if let Some(csv_records_key) = self.csv_records_key.as_ref() {
235                    csv_records_key.as_str()
236                } else {
237                    CSV_RECORDS_DEFAULT_KEY
238                };
239                let json_value = json_value_with_jsonpath_key(jsonpath_key, Value::from(rows));
240
241                let body = serde_json::to_string(&json_value);
242                match body {
243                    Ok(body) => json_response(
244                        body.as_str(),
245                        None,
246                        self.custom_headers.as_ref(),
247                        &self.request_headers,
248                        Some(self.file_path.as_str()),
249                    ),
250                    Err(err) => {
251                        log::error!(
252                            "failed to convert csv records to json response ({}): {}",
253                            self.file_path.as_str(),
254                            err
255                        );
256                        internal_server_error_response(
257                            "failed to convert csv records to json response",
258                            &self.request_headers,
259                        )
260                    }
261                }
262            }
263            Err(err) => {
264                log::error!(
265                    "failed to analyze csv records ({}): {}",
266                    self.file_path.as_str(),
267                    err
268                );
269                internal_server_error_response(
270                    "failed to analyze csv records",
271                    &self.request_headers,
272                )
273            }
274        }
275    }
276
277    /// binary file response
278    ///
279    /// `with_custom_headers` runs *after* `with_binary_body` (RFC 065)
280    /// — previously reversed, the same ordering bug as `json_response`
281    /// (D2): `with_binary_body` always sets a derived `content-type`,
282    /// so applying custom headers first let that overwrite an explicit
283    /// one every time, on every binary `file_path` response (`.png`,
284    /// `.pdf`, …).
285    fn binary_content_type_response(&self) -> Result<hyper::Response<BoxBody>, hyper::http::Error> {
286        let content = self.binary_content.clone().unwrap_or_default().to_owned();
287        let content_type = binary_content_type(self.file_path.as_str());
288        ResponseHandler::default()
289            .with_binary_body(content, Some(content_type))
290            .with_custom_headers(self.custom_headers.as_ref())
291            .into_response(&self.request_headers)
292    }
293}