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