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}