Skip to main content

apimock_server/
dyn_route.rs

1use hyper::HeaderMap;
2use tokio::task;
3
4use std::{fs, path::Path};
5
6use crate::{
7    response::{
8        error_response::{internal_server_error_response, not_found_response},
9        file_response::FileResponse,
10    },
11    types::BoxBody,
12};
13use apimock_routing::util::json::JSON_COMPATIBLE_EXTENSIONS;
14
15/// Serve a request from the fallback `respond_dir` (the file-based "just
16/// drop JSON in a folder" mode).
17///
18/// # Why the matching is case-insensitive and extension-tolerant
19///
20/// This handler powers the zero-config experience where URL paths map
21/// onto files on disk. The two accommodations we make are:
22///
23/// 1. **Case-insensitive filename match** — browsers often canonicalize
24///    paths (`/Users` vs `/users`), and operators rarely care. If a file
25///    matches in a case-insensitive compare we use it.
26/// 2. **Extension inference** — a request to `/foo` with no extension
27///    looks for `foo.json`, `foo.json5`, `foo.csv` in that order, then
28///    `foo/index.*`. This means operators can drop a single JSON file
29///    and use the shortened URL, which matches how most REST APIs are
30///    described in docs.
31pub async fn dyn_route_content(
32    url_path: &str,
33    fallback_respond_dir: &str,
34    request_headers: &HeaderMap,
35) -> Result<hyper::Response<BoxBody>, hyper::http::Error> {
36    let request_path =
37        Path::new(fallback_respond_dir).join(url_path.strip_prefix("/").unwrap_or_default());
38
39    let request_file_name = request_path
40        .file_name()
41        .unwrap_or_default()
42        .to_str()
43        .unwrap_or_default();
44
45    // Locate the parent dir. Missing parent → 404. No parent at all
46    // (e.g. path was empty) → 500, since that indicates a bug elsewhere.
47    let Some(parent) = request_path.parent() else {
48        return internal_server_error_response(
49            &format!("parent dir not found: url_path = {}", url_path),
50            request_headers,
51        );
52    };
53    if !parent.exists() {
54        return not_found_response(request_headers);
55    }
56    let dir = parent.to_owned();
57
58    // Read the directory off the async runtime.
59    //
60    // # Why `spawn_blocking` and not `tokio::fs`
61    //
62    // `tokio::fs` is a thin async wrapper that internally calls
63    // `spawn_blocking` itself. Using it directly would add a layer of
64    // indirection while we iterate a `DirEntry` stream. Since we want
65    // the whole directory listing at once (and it's bounded in size),
66    // doing one `spawn_blocking` for the full listing is simpler and
67    // uses the same thread pool underneath.
68    let dir_for_blocking_task = dir.clone();
69    let read_dir_result = task::spawn_blocking(move || -> Result<Vec<_>, String> {
70        let entries = fs::read_dir(dir_for_blocking_task.as_path()).map_err(|err| {
71            format!(
72                "failed to get dir: {} ({})",
73                dir_for_blocking_task.to_string_lossy(),
74                err
75            )
76        })?;
77        entries
78            .map(|entry| {
79                entry.map_err(|err| {
80                    format!(
81                        "failed to get dir entry from dir: {} ({})",
82                        dir_for_blocking_task.to_string_lossy(),
83                        err
84                    )
85                })
86            })
87            .collect()
88    })
89    .await;
90
91    let entries = match read_dir_result {
92        Ok(Ok(v)) => v,
93        Ok(Err(err)) => {
94            return internal_server_error_response(err.as_str(), request_headers);
95        }
96        Err(err) => {
97            return internal_server_error_response(
98                &format!("failed to get dir entries (async handling: {})", err),
99                request_headers,
100            );
101        }
102    };
103
104    // Case-insensitive exact match within the directory listing.
105    let mut found = entries.into_iter().find_map(|entry| {
106        let path = entry.path();
107        let name = path
108            .file_name()
109            .unwrap_or_default()
110            .to_str()
111            .unwrap_or_default()
112            .to_owned();
113        if name.eq_ignore_ascii_case(request_file_name) {
114            Some(path)
115        } else {
116            None
117        }
118    });
119
120    // Extension inference: `/foo` → `foo.json` / `foo.json5` / `foo.csv`.
121    if found.is_none() && request_path.extension().is_none() {
122        if let Some(stem) = request_path.file_stem().and_then(|s| s.to_str()) {
123            for ext in JSON_COMPATIBLE_EXTENSIONS {
124                let file_path = dir.join(format!("{}.{}", stem, ext));
125                if file_path.exists() {
126                    found = Some(file_path);
127                    break;
128                }
129            }
130        }
131    }
132
133    let Some(found) = found else {
134        return not_found_response(request_headers);
135    };
136
137    let file_path = found.to_str().unwrap_or_default();
138    FileResponse::new(file_path, None, request_headers)
139        .file_content_response()
140        .await
141}