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 pub fn new(file_path: &str) -> ServerResult<Self> {
43 let path = Path::new(file_path);
44 if !path.exists() {
45 return Err(ServerError::MiddlewareMissing {
46 path: path.to_path_buf(),
47 });
48 }
49
50 let engine = Engine::new();
51 // todo: watch source file change - `notify` crate ?
52 let ast = engine
53 .compile_file(file_path.into())
54 .map_err(|e| ServerError::MiddlewareCompile {
55 path: path.to_path_buf(),
56 reason: e.to_string(),
57 })?;
58
59 Ok(MiddlewareHandler {
60 engine: Arc::new(engine),
61 file_path: file_path.to_owned(),
62 ast,
63 })
64 }
65
66 /// Evaluate the middleware for one request.
67 ///
68 /// Returns:
69 /// - `Some(Ok(response))` — the script decided to handle the request
70 /// and produced a response.
71 /// - `Some(Err(_))` — the script tried to handle the request but the
72 /// response could not be built (e.g. invalid header value).
73 /// - `None` — the script returned a value that is neither a string nor
74 /// a map, which is the convention for "let the next layer handle it".
75 ///
76 /// # Why errors here are logged and converted, not propagated
77 ///
78 /// A Rhai runtime error during per-request evaluation is a script bug,
79 /// not a startup config bug. Turning it into an `AppError` would
80 /// force the whole process down, which is the opposite of what an
81 /// HTTP server should do. We instead log and fall through to the
82 /// next handler, producing an HTTP response rather than aborting.
83 pub async fn handle(
84 &self,
85 request_url_path: &str,
86 request_body_json_value: Option<&Value>,
87 request_headers: &HeaderMap,
88 ) -> Option<Result<hyper::Response<BoxBody>, hyper::http::Error>> {
89 let mut scope = Scope::new();
90 scope.push("url_path", request_url_path.to_owned());
91 if let Some(request_body_json_value) = request_body_json_value {
92 match to_dynamic(request_body_json_value) {
93 Ok(body_dynamic) => {
94 scope.push("body", body_dynamic);
95 }
96 Err(err) => {
97 log::warn!(
98 "middleware `{}`: failed to convert request body to Rhai Dynamic: {}",
99 self.file_path,
100 err
101 );
102 return None;
103 }
104 }
105 }
106
107 // middleware response
108 let rhai_response = match self
109 .engine
110 .eval_ast_with_scope::<Dynamic>(&mut scope, &self.ast)
111 {
112 Ok(v) => v,
113 Err(err) => {
114 log::warn!(
115 "middleware `{}`: script evaluation failed: {}",
116 self.file_path,
117 err
118 );
119 return None;
120 }
121 };
122
123 if !rhai_response.is_string() && !rhai_response.is_map() {
124 return None;
125 }
126 let middleware_response = MiddlewareResponse::new(self.file_path.as_str(), request_headers);
127
128 // string is treated as file path
129 if let Some(x) = rhai_response.clone().try_cast::<String>() {
130 middleware_response.file_response(x.as_str()).await
131 // map may be as either of: file path, json response string, text response string
132 } else if let Some(x) = rhai_response.try_cast::<Map>() {
133 if let Some(x) = x
134 .get("file_path")
135 .and_then(|x| x.clone().try_cast::<String>())
136 {
137 middleware_response.file_response(x.as_str()).await
138 } else if let Some(x) = x.get("json").and_then(|x| x.clone().try_cast::<String>()) {
139 middleware_response.json_response(x.as_str())
140 } else if let Some(x) = x.get("text").and_then(|x| x.clone().try_cast::<String>()) {
141 middleware_response.text_response(x.as_str())
142 } else {
143 None
144 }
145 } else {
146 None
147 }
148 }
149}