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