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    /// RFC 067 — see `response_handler::default_response_headers`.
39    cors_allow_credentials_origins: Vec<String>,
40}
41
42impl FileResponse {
43    /// create instance
44    pub fn new(
45        file_path: &str,
46        custom_headers: Option<&HashMap<String, Option<String>>>,
47        request_headers: &HeaderMap,
48        confine_to: Option<&Path>,
49        cors_allow_credentials_origins: &[String],
50    ) -> Self {
51        FileResponse {
52            file_path: file_path.to_owned(),
53            csv_records_key: None,
54            text_content: None,
55            binary_content: None,
56            custom_headers: custom_headers.cloned(),
57            request_headers: request_headers.clone(),
58            confine_to: confine_to.map(Path::to_path_buf),
59            cors_allow_credentials_origins: cors_allow_credentials_origins.to_vec(),
60        }
61    }
62
63    /// create instance
64    pub fn new_with_csv_records_jsonpath(
65        file_path: &str,
66        custom_headers: Option<&HashMap<String, Option<String>>>,
67        csv_records_key: Option<String>,
68        request_headers: &HeaderMap,
69        confine_to: Option<&Path>,
70        cors_allow_credentials_origins: &[String],
71    ) -> Self {
72        let mut ret = FileResponse::new(
73            file_path,
74            custom_headers,
75            request_headers,
76            confine_to,
77            cors_allow_credentials_origins,
78        );
79        ret.csv_records_key = csv_records_key;
80        ret
81    }
82
83    /// response from file path
84    pub async fn file_content_response(
85        &mut self,
86    ) -> Result<hyper::Response<BoxBody>, hyper::http::Error> {
87        let file_path = match resolve_with_json_compatible_extensions(self.file_path.as_str()) {
88            Some(x) => x,
89            None => {
90                log::warn!(
91                    "{}:\n{} (missing or a directory)",
92                    style("file not found").red(),
93                    self.file_path
94                );
95                return not_found_response(
96                    &self.request_headers,
97                    &self.cors_allow_credentials_origins,
98                );
99            }
100        };
101
102        // Confine the resolved candidate to the directory it was meant
103        // to come from. This runs after extension/`index.*` resolution
104        // above, so it also catches a path that only escapes at that
105        // stage (e.g. a symlinked `index.html`), not only one that
106        // arrived already outside.
107        let file_path = match confine(file_path.as_str(), self.confine_to.as_deref()) {
108            Some(canonical) => match canonical.to_str() {
109                Some(x) => x.to_owned(),
110                None => {
111                    log::error!(
112                        "{} to get str from canonicalized file path:\n{}",
113                        style("failed").red(),
114                        file_path
115                    );
116                    return not_found_response(
117                        &self.request_headers,
118                        &self.cors_allow_credentials_origins,
119                    );
120                }
121            },
122            None => {
123                return not_found_response(
124                    &self.request_headers,
125                    &self.cors_allow_credentials_origins,
126                );
127            }
128        };
129        self.file_path = file_path.clone();
130
131        // RFC 077 P-05: read the file's bytes once, then decide
132        // text-vs-binary from the bytes already in hand — this used to
133        // be two blocking reads (`read_to_string`, and on its failure a
134        // second `read` of the same file for the binary fallback).
135        // `String::from_utf8` reproduces exactly the same dispatch
136        // `read_to_string`'s success/failure did (it fails on the same
137        // input `read_to_string` would have failed to decode), so the
138        // detection RFC 065's review pinned as load-bearing is
139        // unchanged — see the tests below, written before this diff.
140        let file_path_to_read = file_path.clone();
141        let content = task::spawn_blocking(move || fs::read(file_path_to_read)).await;
142
143        match content {
144            Ok(Ok(bytes)) => match String::from_utf8(bytes) {
145                Ok(text) => {
146                    self.text_content = Some(text);
147                    self.text_file_content_response()
148                }
149                Err(err) => {
150                    self.binary_content = Some(err.into_bytes());
151                    self.binary_content_type_response()
152                }
153            },
154            Ok(Err(err)) => {
155                log::error!("failed to read file ({}): {}", self.file_path, err);
156                internal_server_error_response(
157                    "failed to read response file",
158                    &self.request_headers,
159                    &self.cors_allow_credentials_origins,
160                )
161            }
162            Err(err) => {
163                log::error!("async task failed ({}): {}", self.file_path, err);
164                internal_server_error_response(
165                    "failed to read response file",
166                    &self.request_headers,
167                    &self.cors_allow_credentials_origins,
168                )
169            }
170        }
171    }
172
173    /// text file response
174    ///
175    /// `self.custom_headers` is threaded through here the same way
176    /// `json_file_content_response`/`csv_file_content_response` already
177    /// do below - this branch previously hardcoded `None`, silently
178    /// dropping every custom header on a plain-text `file_path` response
179    /// (RFC 045 Defect 1, extended: this contradicted the RFC's own
180    /// "`file_path` | honoured" claim, which held only for the
181    /// json/json5/csv sub-cases).
182    fn text_file_content_response(&self) -> Result<hyper::Response<BoxBody>, hyper::http::Error> {
183        match file_extension(self.file_path.as_str()) {
184            Some(ext) => match ext.as_str() {
185                // RFC 076: `.json` is served exactly as written — no
186                // parse/reserialise round-trip. `.json5` still converts
187                // (JSON5 is not JSON; converting it is the point, and a
188                // user writing JSON5 has already accepted a
189                // transformation) via `json_file_content_response` below,
190                // unchanged.
191                "json" => self.raw_json_file_content_response(),
192                "json5" => self.json_file_content_response(),
193                "csv" => self.csv_file_content_response(),
194                _ => text_response(
195                    self.text_content.clone().unwrap_or_default().as_str(),
196                    Some(text_file_content_type(ext).as_str()),
197                    self.custom_headers.as_ref(),
198                    &self.request_headers,
199                    &self.cors_allow_credentials_origins,
200                ),
201            },
202            None => text_response(
203                self.text_content.clone().unwrap_or_default().as_str(),
204                None,
205                self.custom_headers.as_ref(),
206                &self.request_headers,
207                &self.cors_allow_credentials_origins,
208            ),
209        }
210    }
211
212    /// `.json` file response — served byte-for-byte, no parsing.
213    ///
214    /// # RFC 076: why skipping the parse/reserialise round-trip is safe
215    ///
216    /// The old path here (still used for `.json5`, see
217    /// `json_file_content_response` below) parsed the file into a
218    /// `Value` and reserialised it — minifying it and, before RFC 076's
219    /// `preserve_order`, reordering keys alphabetically, relative to
220    /// what was on disk. Neither is a validity check: RFC 065 already
221    /// validates every `.json`/`.json5` `file_path` at config-load time
222    /// (`Respond::validate`, the same JSON5 parser this module still
223    /// uses for `.json5`), so parsing again here bought nothing but the
224    /// two side effects above. Serving the bytes this method already
225    /// read (`self.text_content`) is therefore both the byte-identical
226    /// fix and the elimination of a redundant parse. If RFC 065's
227    /// load-time validation is ever loosened or removed, this comment is
228    /// the signal to reconsider whether a content check belongs here.
229    fn raw_json_file_content_response(
230        &self,
231    ) -> Result<hyper::Response<BoxBody>, hyper::http::Error> {
232        let json_str = self.text_content.clone().unwrap_or_default();
233        ResponseHandler::default()
234            .with_json_body(json_str)
235            .with_custom_headers(self.custom_headers.as_ref())
236            .into_response(&self.request_headers, &self.cors_allow_credentials_origins)
237    }
238
239    /// `.json5` file response — parsed and reserialised (unchanged by
240    /// RFC 076; converting JSON5 to JSON is the point, not a defect).
241    fn json_file_content_response(&self) -> Result<hyper::Response<BoxBody>, hyper::http::Error> {
242        let json_str = self.text_content.clone().unwrap_or_default();
243        json_response(
244            json_str.as_str(),
245            None,
246            self.custom_headers.as_ref(),
247            &self.request_headers,
248            Some(self.file_path.as_str()),
249            &self.cors_allow_credentials_origins,
250        )
251    }
252
253    /// csv file response
254    fn csv_file_content_response(&self) -> Result<hyper::Response<BoxBody>, hyper::http::Error> {
255        let text_content = self.text_content.clone().unwrap_or_default();
256        let mut rdr = csv::ReaderBuilder::new()
257            .has_headers(true)
258            .from_reader(text_content.as_bytes());
259
260        let csv_headers = if let Ok(csv_headers) = rdr.headers() {
261            csv_headers.clone()
262        } else {
263            log::error!(
264                "failed to analyze csv headers ({})",
265                self.file_path.as_str()
266            );
267            return internal_server_error_response(
268                "failed to analyze csv headers",
269                &self.request_headers,
270                &self.cors_allow_credentials_origins,
271            );
272        };
273
274        let rows = rdr
275            .records()
276            .map(|result| {
277                let record = result?;
278                let obj = csv_headers
279                    .iter()
280                    .zip(record.iter())
281                    .map(|(k, v)| (k.to_string(), Value::String(v.to_string())))
282                    .collect::<Map<_, _>>();
283                Ok(Value::Object(obj))
284            })
285            .collect::<Result<Vec<Value>, csv::Error>>();
286
287        match rows {
288            Ok(rows) => {
289                let jsonpath_key = if let Some(csv_records_key) = self.csv_records_key.as_ref() {
290                    csv_records_key.as_str()
291                } else {
292                    CSV_RECORDS_DEFAULT_KEY
293                };
294                let json_value = json_value_with_jsonpath_key(jsonpath_key, Value::from(rows));
295
296                let body = serde_json::to_string(&json_value);
297                match body {
298                    Ok(body) => json_response(
299                        body.as_str(),
300                        None,
301                        self.custom_headers.as_ref(),
302                        &self.request_headers,
303                        Some(self.file_path.as_str()),
304                        &self.cors_allow_credentials_origins,
305                    ),
306                    Err(err) => {
307                        log::error!(
308                            "failed to convert csv records to json response ({}): {}",
309                            self.file_path.as_str(),
310                            err
311                        );
312                        internal_server_error_response(
313                            "failed to convert csv records to json response",
314                            &self.request_headers,
315                            &self.cors_allow_credentials_origins,
316                        )
317                    }
318                }
319            }
320            Err(err) => {
321                log::error!(
322                    "failed to analyze csv records ({}): {}",
323                    self.file_path.as_str(),
324                    err
325                );
326                internal_server_error_response(
327                    "failed to analyze csv records",
328                    &self.request_headers,
329                    &self.cors_allow_credentials_origins,
330                )
331            }
332        }
333    }
334
335    /// binary file response
336    ///
337    /// `with_custom_headers` runs *after* `with_binary_body` (RFC 065)
338    /// — previously reversed, the same ordering bug as `json_response`
339    /// (D2): `with_binary_body` always sets a derived `content-type`,
340    /// so applying custom headers first let that overwrite an explicit
341    /// one every time, on every binary `file_path` response (`.png`,
342    /// `.pdf`, …).
343    fn binary_content_type_response(&self) -> Result<hyper::Response<BoxBody>, hyper::http::Error> {
344        let content = self.binary_content.clone().unwrap_or_default().to_owned();
345        let content_type = binary_content_type(self.file_path.as_str());
346        ResponseHandler::default()
347            .with_binary_body(content, Some(content_type))
348            .with_custom_headers(self.custom_headers.as_ref())
349            .into_response(&self.request_headers, &self.cors_allow_credentials_origins)
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    //! RFC 077 P-05: pinned *before* the read-twice-into-one-read
356    //! refactor, per the tranche handoff. The dispatch between
357    //! `text_file_content_response` and `binary_content_type_response`
358    //! is decided by whether the file's bytes are valid UTF-8 — RFC
359    //! 065's review established this as load-bearing — never by the
360    //! file's extension. Both tests below deliberately mismatch
361    //! extension against content to make that point unambiguous: a
362    //! `.txt` file of invalid-UTF-8 bytes must still be served binary,
363    //! and a `.bin` file of valid-UTF-8 bytes must still be served text.
364    use hyper::HeaderMap;
365
366    use super::*;
367    use crate::response::confine::canonical_dir;
368
369    #[tokio::test]
370    async fn invalid_utf8_bytes_are_served_as_binary_regardless_of_a_text_extension() {
371        let dir = tempfile::tempdir().unwrap();
372        let bytes: &[u8] = &[0xFF, 0xFE, 0x00, 0x01, 0x02];
373        let file_path = dir.path().join("weird.txt");
374        std::fs::write(&file_path, bytes).unwrap();
375        let confine_to = canonical_dir(dir.path().to_str().unwrap());
376
377        let mut file_response = FileResponse::new(
378            file_path.to_str().unwrap(),
379            None,
380            &HeaderMap::new(),
381            confine_to.as_deref(),
382            &[],
383        );
384        let response = file_response.file_content_response().await.unwrap();
385
386        assert_eq!(response.status(), hyper::StatusCode::OK);
387        assert_eq!(
388            response.headers().get("content-type").unwrap(),
389            "application/octet-stream",
390            "invalid-UTF-8 bytes must take the binary path even though the \
391             extension says .txt"
392        );
393        let body = http_body_util::BodyExt::collect(response.into_body())
394            .await
395            .unwrap()
396            .to_bytes();
397        assert_eq!(body.as_ref(), bytes, "binary bytes must round-trip exactly");
398    }
399
400    #[tokio::test]
401    async fn valid_utf8_bytes_are_served_as_text_regardless_of_a_binary_extension() {
402        let dir = tempfile::tempdir().unwrap();
403        let text = "hello, this is plain text";
404        let file_path = dir.path().join("weird.bin");
405        std::fs::write(&file_path, text).unwrap();
406        let confine_to = canonical_dir(dir.path().to_str().unwrap());
407
408        let mut file_response = FileResponse::new(
409            file_path.to_str().unwrap(),
410            None,
411            &HeaderMap::new(),
412            confine_to.as_deref(),
413            &[],
414        );
415        let response = file_response.file_content_response().await.unwrap();
416
417        assert_eq!(response.status(), hyper::StatusCode::OK);
418        assert_eq!(
419            response.headers().get("content-type").unwrap(),
420            "text/plain; charset=utf-8",
421            "valid-UTF-8 bytes must take the text path even though the \
422             extension says .bin"
423        );
424        let body = http_body_util::BodyExt::collect(response.into_body())
425            .await
426            .unwrap()
427            .to_bytes();
428        assert_eq!(body.as_ref(), text.as_bytes());
429    }
430
431    /// RFC 076: a `.json` file with non-alphabetical keys and pretty
432    /// (non-minified) formatting is served byte-for-byte — comparing
433    /// bytes, not parsed equality, since a parse-and-recompare would
434    /// pass with the old minify-and-reorder behaviour still in place.
435    #[tokio::test]
436    async fn json_file_is_served_byte_identical_key_order_and_formatting_preserved() {
437        let dir = tempfile::tempdir().unwrap();
438        let bytes: &[u8] = b"{\n  \"zebra\": 1,\n  \"apple\": 2\n}\n";
439        let file_path = dir.path().join("data.json");
440        std::fs::write(&file_path, bytes).unwrap();
441        let confine_to = canonical_dir(dir.path().to_str().unwrap());
442
443        let mut file_response = FileResponse::new(
444            file_path.to_str().unwrap(),
445            None,
446            &HeaderMap::new(),
447            confine_to.as_deref(),
448            &[],
449        );
450        let response = file_response.file_content_response().await.unwrap();
451
452        assert_eq!(response.status(), hyper::StatusCode::OK);
453        assert_eq!(
454            response.headers().get("content-type").unwrap(),
455            "application/json"
456        );
457        let body = http_body_util::BodyExt::collect(response.into_body())
458            .await
459            .unwrap()
460            .to_bytes();
461        assert_eq!(
462            body.as_ref(),
463            bytes,
464            "a .json file must be served exactly as written — not \
465             minified, not key-reordered"
466        );
467    }
468
469    /// RFC 076 non-goal: `.json5` still converts (parses and
470    /// reserialises) — JSON5 is not JSON, and converting it is the
471    /// point. Pinned so the `.json`/`.json5` split above can't
472    /// accidentally start treating them the same.
473    #[tokio::test]
474    async fn json5_file_still_converts_to_minified_json() {
475        let dir = tempfile::tempdir().unwrap();
476        // Trailing comma and unquoted-friendly spacing: valid JSON5,
477        // invalid strict JSON — proves this path still goes through the
478        // JSON5 parser rather than being served as raw bytes.
479        let source = b"{\n  \"zebra\": 1,\n  \"apple\": 2,\n}\n";
480        let file_path = dir.path().join("data.json5");
481        std::fs::write(&file_path, source).unwrap();
482        let confine_to = canonical_dir(dir.path().to_str().unwrap());
483
484        let mut file_response = FileResponse::new(
485            file_path.to_str().unwrap(),
486            None,
487            &HeaderMap::new(),
488            confine_to.as_deref(),
489            &[],
490        );
491        let response = file_response.file_content_response().await.unwrap();
492
493        assert_eq!(response.status(), hyper::StatusCode::OK);
494        let body = http_body_util::BodyExt::collect(response.into_body())
495            .await
496            .unwrap()
497            .to_bytes();
498        // Converted: minified, trailing comma gone. Key order is a
499        // separate question (RFC 076's `preserve_order`) — this test
500        // only pins that conversion still happens at all.
501        assert_ne!(
502            body.as_ref(),
503            source,
504            ".json5 must still be converted, not served raw"
505        );
506        let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
507        assert_eq!(parsed["zebra"], 1);
508        assert_eq!(parsed["apple"], 2);
509    }
510}