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}