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/// Handler for a single Rhai middleware script.
18///
19/// # Why the AST is compiled once at startup
20///
21/// Rhai offers both "compile on every evaluation" and "compile once, re-run
22/// the AST" modes. Middleware is invoked on the hot path (every request),
23/// so we keep the compiled `AST` alongside the `Engine` and only evaluate
24/// at request time. This trades a small amount of memory for a large
25/// throughput win and keeps parse errors as startup failures instead of
26/// per-request 500s.
27///
28/// The `Engine` is wrapped in `Arc` so that `MiddlewareHandler` can be
29/// cloned cheaply into each request task without deep-cloning the
30/// interpreter state.
31#[derive(Clone)]
32#[non_exhaustive]
33pub struct MiddlewareHandler {
34    pub engine: Arc<Engine>,
35    pub file_path: String,
36    pub ast: AST,
37    /// The middleware script's own directory, canonicalised once here
38    /// at compile time. A file path the script returns is confined to
39    /// this directory the same way a rule's `respond.file_path` is
40    /// confined to `respond_dir` — see [`MiddlewareResponse::file_response`].
41    pub confine_to: Option<PathBuf>,
42}
43
44impl MiddlewareHandler {
45    /// Compile a middleware script from disk into a reusable handler.
46    ///
47    /// Returns an `AppError` on either a missing file or a compile-time
48    /// Rhai parse error. Callers treat both as startup-time failures —
49    /// we deliberately do not try to recover by, say, skipping the offending
50    /// script, because silently ignoring a misconfigured middleware would
51    /// produce confusing request-time behaviour.
52    pub fn new(file_path: &str) -> ServerResult<Self> {
53        let path = Path::new(file_path);
54        if !path.exists() {
55            return Err(ServerError::MiddlewareMissing {
56                path: path.to_path_buf(),
57            });
58        }
59
60        let engine = Engine::new();
61        // todo: watch source file change - `notify` crate ?
62        let ast =
63            engine
64                .compile_file(file_path.into())
65                .map_err(|e| ServerError::MiddlewareCompile {
66                    path: path.to_path_buf(),
67                    reason: e.to_string(),
68                })?;
69
70        let confine_to = path
71            .parent()
72            .and_then(|p| p.to_str())
73            .and_then(canonical_dir);
74
75        Ok(MiddlewareHandler {
76            engine: Arc::new(engine),
77            file_path: file_path.to_owned(),
78            ast,
79            confine_to,
80        })
81    }
82
83    /// Evaluate the middleware for one request.
84    ///
85    /// Returns:
86    /// - `Some(Ok(response))` — the script decided to handle the request
87    ///   and produced a response.
88    /// - `Some(Err(_))` — the script tried to handle the request but the
89    ///   response could not be built (e.g. invalid header value).
90    /// - `None` — the script returned a value that is neither a string nor
91    ///   a map, which is the convention for "let the next layer handle it".
92    ///
93    /// # Why errors here are logged and converted, not propagated
94    ///
95    /// A Rhai runtime error during per-request evaluation is a script bug,
96    /// not a startup config bug. Turning it into an `AppError` would
97    /// force the whole process down, which is the opposite of what an
98    /// HTTP server should do. We instead log and fall through to the
99    /// next handler, producing an HTTP response rather than aborting.
100    pub async fn handle(
101        &self,
102        request_url_path: &str,
103        request_body_json_value: Option<&Value>,
104        request_headers: &HeaderMap,
105    ) -> Option<Result<hyper::Response<BoxBody>, hyper::http::Error>> {
106        let mut scope = Scope::new();
107        scope.push("url_path", request_url_path.to_owned());
108        if let Some(request_body_json_value) = request_body_json_value {
109            match to_dynamic(request_body_json_value) {
110                Ok(body_dynamic) => {
111                    scope.push("body", body_dynamic);
112                }
113                Err(err) => {
114                    log::warn!(
115                        "middleware `{}`: failed to convert request body to Rhai Dynamic: {}",
116                        self.file_path,
117                        err
118                    );
119                    return None;
120                }
121            }
122        }
123
124        // middleware response
125        let rhai_response = match self
126            .engine
127            .eval_ast_with_scope::<Dynamic>(&mut scope, &self.ast)
128        {
129            Ok(v) => v,
130            Err(err) => {
131                log::warn!(
132                    "middleware `{}`: script evaluation failed: {}",
133                    self.file_path,
134                    err
135                );
136                return None;
137            }
138        };
139
140        if !rhai_response.is_string() && !rhai_response.is_map() {
141            return None;
142        }
143        let middleware_response = MiddlewareResponse::new(
144            self.file_path.as_str(),
145            request_headers,
146            self.confine_to.as_deref(),
147        );
148
149        // string is treated as file path
150        if let Some(x) = rhai_response.clone().try_cast::<String>() {
151            middleware_response.file_response(x.as_str()).await
152        // map may be as either of: file path, json response string, text response string
153        } else if let Some(x) = rhai_response.try_cast::<Map>() {
154            if let Some(x) = x
155                .get("file_path")
156                .and_then(|x| x.clone().try_cast::<String>())
157            {
158                middleware_response.file_response(x.as_str()).await
159            } else if let Some(x) = x.get("json").and_then(|x| x.clone().try_cast::<String>()) {
160                middleware_response.json_response(x.as_str())
161            } else if let Some(x) = x.get("text").and_then(|x| x.clone().try_cast::<String>()) {
162                middleware_response.text_response(x.as_str())
163            } else {
164                None
165            }
166        } else {
167            None
168        }
169    }
170}