Skip to main content

apimock_server/middleware/
middleware_handler.rs

1use hyper::HeaderMap;
2use rhai::{AST, Dynamic, Engine, Map, Scope, serde::to_dynamic};
3use serde_json::Value;
4
5use std::{
6    path::{Path, PathBuf},
7    sync::Arc,
8};
9
10use crate::{
11    error::{ServerError, ServerResult},
12    middleware::middleware_response::MiddlewareResponse,
13    response::confine::canonical_dir,
14    types::BoxBody,
15};
16
17/// RFC 068 S-03: bounds on a middleware script that aren't the
18/// operator-configurable `middleware_max_operations` — fixed,
19/// generous ceilings on call depth and string/array/map growth so a
20/// script's *shape* can't run away even within its operation budget.
21/// Not configurable: unlike the operation count, there's no
22/// legitimate mock-middleware reason to need deeper recursion or
23/// bigger single values than this.
24mod limits {
25    /// Rhai's own default in release builds; set explicitly here so the
26    /// bound doesn't silently change between debug and release builds.
27    pub const MAX_CALL_LEVELS: usize = 64;
28    /// Characters in a single Rhai string value.
29    pub const MAX_STRING_SIZE: usize = 1_000_000;
30    /// Elements in a single Rhai array value.
31    pub const MAX_ARRAY_SIZE: usize = 100_000;
32    /// Entries in a single Rhai object-map value.
33    pub const MAX_MAP_SIZE: usize = 10_000;
34}
35
36/// Handler for a single Rhai middleware script.
37///
38/// # Why the AST is compiled once at startup
39///
40/// Rhai offers both "compile on every evaluation" and "compile once, re-run
41/// the AST" modes. Middleware is invoked on the hot path (every request),
42/// so we keep the compiled `AST` alongside the `Engine` and only evaluate
43/// at request time. This trades a small amount of memory for a large
44/// throughput win and keeps parse errors as startup failures instead of
45/// per-request 500s.
46///
47/// The `Engine` is wrapped in `Arc` so that `MiddlewareHandler` can be
48/// cloned cheaply into each request task without deep-cloning the
49/// interpreter state.
50#[derive(Clone)]
51#[non_exhaustive]
52pub struct MiddlewareHandler {
53    pub engine: Arc<Engine>,
54    pub file_path: String,
55    pub ast: AST,
56    /// The middleware script's own directory, canonicalised once here
57    /// at compile time. A file path the script returns is confined to
58    /// this directory the same way a rule's `respond.file_path` is
59    /// confined to `respond_dir` — see `MiddlewareResponse::file_response`
60    /// (private to this crate; not linked here since rustdoc's public
61    /// docs can't resolve a private item, and widening its visibility
62    /// is a separate, deliberate decision — not this comment's to make).
63    pub confine_to: Option<PathBuf>,
64}
65
66impl MiddlewareHandler {
67    /// Compile a middleware script from disk into a reusable handler.
68    ///
69    /// Returns an `AppError` on either a missing file or a compile-time
70    /// Rhai parse error. Callers treat both as startup-time failures —
71    /// we deliberately do not try to recover by, say, skipping the offending
72    /// script, because silently ignoring a misconfigured middleware would
73    /// produce confusing request-time behaviour.
74    ///
75    /// # `max_operations` (RFC 068 S-03)
76    ///
77    /// `Engine::new()` used to set no limits at all — not
78    /// `set_max_operations`, not `set_max_call_levels`, not the
79    /// string/array size caps — so a non-terminating script (a `while
80    /// true` an operator is actively developing is the ordinary case,
81    /// not an attack) ran forever. `max_operations` bounds a script by
82    /// work done; call-depth and string/array/map growth get fixed,
83    /// generous ceilings from `limits` regardless of what's configured
84    /// here, since there's no legitimate reason a mock middleware needs
85    /// more of either. Neither is the whole fix on its own — see
86    /// [`handle`](Self::handle)'s doc comment for the other half.
87    pub fn new(file_path: &str, max_operations: u64) -> ServerResult<Self> {
88        let path = Path::new(file_path);
89        if !path.exists() {
90            return Err(ServerError::MiddlewareMissing {
91                path: path.to_path_buf(),
92            });
93        }
94
95        let mut engine = Engine::new();
96        engine.set_max_operations(max_operations);
97        engine.set_max_call_levels(limits::MAX_CALL_LEVELS);
98        engine.set_max_string_size(limits::MAX_STRING_SIZE);
99        engine.set_max_array_size(limits::MAX_ARRAY_SIZE);
100        engine.set_max_map_size(limits::MAX_MAP_SIZE);
101
102        // todo: watch source file change - `notify` crate ?
103        let ast =
104            engine
105                .compile_file(file_path.into())
106                .map_err(|e| ServerError::MiddlewareCompile {
107                    path: path.to_path_buf(),
108                    reason: e.to_string(),
109                })?;
110
111        let confine_to = path
112            .parent()
113            .and_then(|p| p.to_str())
114            .and_then(canonical_dir);
115
116        Ok(MiddlewareHandler {
117            engine: Arc::new(engine),
118            file_path: file_path.to_owned(),
119            ast,
120            confine_to,
121        })
122    }
123
124    /// Evaluate the middleware for one request.
125    ///
126    /// Returns:
127    /// - `Some(Ok(response))` — the script decided to handle the request
128    ///   and produced a response.
129    /// - `Some(Err(_))` — the script tried to handle the request but the
130    ///   response could not be built (e.g. invalid header value).
131    /// - `None` — the script returned a value that is neither a string nor
132    ///   a map, which is the convention for "let the next layer handle it".
133    ///
134    /// # Why errors here are logged and converted, not propagated
135    ///
136    /// A Rhai runtime error during per-request evaluation is a script bug,
137    /// not a startup config bug. Turning it into an `AppError` would
138    /// force the whole process down, which is the opposite of what an
139    /// HTTP server should do. We instead log and fall through to the
140    /// next handler, producing an HTTP response rather than aborting.
141    ///
142    /// # Why evaluation runs in `spawn_blocking` (RFC 068 S-03)
143    ///
144    /// This used to call `eval_ast_with_scope` directly, synchronously,
145    /// on the async runtime's own worker thread. `max_operations`
146    /// (`Self::new`) bounds a runaway script by work done, but that is
147    /// a value an operator can raise, and it does nothing for a script
148    /// blocked on something that isn't a counted operation. Moving
149    /// evaluation into `spawn_blocking` is what turns the failure mode
150    /// from "one fewer tokio worker, permanently" into "one slow
151    /// request" — the same reason file reads already go through
152    /// `spawn_blocking` elsewhere in this crate. Rhai's `sync` feature
153    /// is enabled, so `Engine`/`AST` are `Send` and this is possible
154    /// without a dependency change.
155    pub async fn handle(
156        &self,
157        request_url_path: &str,
158        request_body_json_value: Option<&Value>,
159        request_headers: &HeaderMap,
160        cors_allow_credentials_origins: &[String],
161    ) -> Option<Result<hyper::Response<BoxBody>, hyper::http::Error>> {
162        let mut scope = Scope::new();
163        scope.push("url_path", request_url_path.to_owned());
164        if let Some(request_body_json_value) = request_body_json_value {
165            match to_dynamic(request_body_json_value) {
166                Ok(body_dynamic) => {
167                    scope.push("body", body_dynamic);
168                }
169                Err(err) => {
170                    log::warn!(
171                        "middleware `{}`: failed to convert request body to Rhai Dynamic: {}",
172                        self.file_path,
173                        err
174                    );
175                    return None;
176                }
177            }
178        }
179
180        // middleware response — see this method's doc comment for why
181        // this is a blocking task, not a direct call.
182        let engine = Arc::clone(&self.engine);
183        let ast = self.ast.clone();
184        let eval_result = match tokio::task::spawn_blocking(move || {
185            engine.eval_ast_with_scope::<Dynamic>(&mut scope, &ast)
186        })
187        .await
188        {
189            Ok(result) => result,
190            Err(join_err) => {
191                log::warn!(
192                    "middleware `{}`: evaluation task panicked or was cancelled: {}",
193                    self.file_path,
194                    join_err
195                );
196                return None;
197            }
198        };
199        let rhai_response = match eval_result {
200            Ok(v) => v,
201            Err(err) => {
202                log::warn!(
203                    "middleware `{}`: script evaluation failed: {}",
204                    self.file_path,
205                    err
206                );
207                return None;
208            }
209        };
210
211        if !rhai_response.is_string() && !rhai_response.is_map() {
212            return None;
213        }
214        let middleware_response = MiddlewareResponse::new(
215            self.file_path.as_str(),
216            request_headers,
217            self.confine_to.as_deref(),
218            cors_allow_credentials_origins,
219        );
220
221        // string is treated as file path
222        if let Some(x) = rhai_response.clone().try_cast::<String>() {
223            middleware_response.file_response(x.as_str()).await
224        // map may be as either of: file path, json response string, text response string
225        } else if let Some(x) = rhai_response.try_cast::<Map>() {
226            if let Some(x) = x
227                .get("file_path")
228                .and_then(|x| x.clone().try_cast::<String>())
229            {
230                middleware_response.file_response(x.as_str()).await
231            } else if let Some(x) = x.get("json").and_then(|x| x.clone().try_cast::<String>()) {
232                middleware_response.json_response(x.as_str())
233            } else if let Some(x) = x.get("text").and_then(|x| x.clone().try_cast::<String>()) {
234                middleware_response.text_response(x.as_str())
235            } else {
236                None
237            }
238        } else {
239            None
240        }
241    }
242}