Skip to main content

sendra_core/script/
run.rs

1//! The two entry points a caller actually runs: `pre_request` against a
2//! request, `post_request` against a response.
3
4use rhai::{Dynamic, Scope};
5
6use crate::{Request, Response, SendraError};
7
8use super::engine::{capture, failure_message};
9use super::marshal::{request_from_dynamic, request_map, response_map};
10use super::{Hook, Script, ScriptOutcome, ScriptOutput};
11
12/// Run a `pre_request` script and return the request it left behind, along
13/// with anything it printed.
14///
15/// The script sees `request` as an object map — see [`request_map`] for the
16/// exact shape — mutates it in place, and this reads it back out and validates
17/// it. Errors are [`SendraError`] rather than a [`ScriptOutcome`] because
18/// there is no response and never will be: whatever went wrong, this request is
19/// not being sent, which is the same category of failure as a missing variable
20/// or a refused connection.
21///
22/// The [`ScriptOutput`] comes back **whether the script succeeded or not**,
23/// which is why it is beside the `Result` rather than inside its `Ok`: a script
24/// that printed three lines and then threw printed three lines, and those are
25/// usually the three lines that explain the throw. Losing them on the error
26/// path would lose them exactly when they are worth the most.
27pub fn run_pre_request(
28    script: &Script,
29    request: &Request,
30) -> (Result<Request, SendraError>, ScriptOutput) {
31    debug_assert_eq!(script.hook, Hook::PreRequest);
32
33    let mut scope = Scope::new();
34    scope.push("request", request_map(request));
35
36    let (result, output) = capture(|engine| engine.run_ast_with_scope(&mut scope, &script.ast));
37
38    let result = result
39        .map_err(|err| SendraError::ScriptFailed {
40            hook: Hook::PreRequest,
41            message: failure_message(&err),
42        })
43        .and_then(|()| {
44            let left_behind = scope.get_value::<Dynamic>("request").expect(
45                "`request` was pushed into the scope and a script cannot remove a variable",
46            );
47
48            request_from_dynamic(request, left_behind)
49        });
50
51    (result, output)
52}
53
54/// Run a `post_request` script against a response, returning its verdict and
55/// anything it printed.
56///
57/// The verdict is infallible by design: every way this can go wrong is a
58/// statement about the response, and the caller reports all of them the same
59/// way. The response is pushed as a *constant*, so assigning to it is Rhai's
60/// own error rather than a silent no-op — see [`response_map`].
61pub fn run_post_request(script: &Script, response: &Response) -> (ScriptOutcome, ScriptOutput) {
62    debug_assert_eq!(script.hook, Hook::PostRequest);
63
64    let mut scope = Scope::new();
65    scope.push_constant("response", response_map(response));
66
67    let (result, output) = capture(|engine| engine.run_ast_with_scope(&mut scope, &script.ast));
68
69    let outcome = match result {
70        Ok(()) => ScriptOutcome::Passed,
71        Err(err) => ScriptOutcome::Failed {
72            message: failure_message(&err),
73        },
74    };
75
76    (outcome, output)
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    use crate::Method;
84
85    use super::super::test_support::{
86        response, run_post, run_pre, with_post_request, with_pre_request,
87    };
88
89    // --- pre_request ------------------------------------------------------
90
91    #[test]
92    fn a_pre_request_script_can_add_a_header() {
93        let request = with_pre_request(r#"request.headers["X-Signature"] = "abc";"#);
94        let sent = run_pre(&request).expect("the script should run");
95
96        assert_eq!(sent.header("X-Signature"), Some("abc"));
97        // And leaves everything else exactly as it was.
98        assert_eq!(sent.header("Accept"), Some("application/json"));
99        assert_eq!(sent.url, "https://example.com/orders");
100        assert_eq!(sent.method, Method::Post);
101        assert_eq!(sent.body.as_deref(), Some(r#"{"id":1}"#));
102    }
103
104    #[test]
105    fn a_pre_request_script_can_modify_and_remove_a_header() {
106        let request = with_pre_request(
107            "request.headers[\"Accept\"] = \"text/plain\";\nrequest.headers.remove(\"Nope\");",
108        );
109        let sent = run_pre(&request).expect("the script should run");
110        assert_eq!(sent.header("Accept"), Some("text/plain"));
111
112        // Removing one that is there really removes it — the script is the last
113        // thing to touch the request, so nothing puts it back.
114        let request = with_pre_request(r#"request.headers.remove("Accept");"#);
115        let sent = run_pre(&request).expect("the script should run");
116        assert!(sent.headers.is_empty(), "{:?}", sent.headers);
117    }
118
119    #[test]
120    fn a_pre_request_script_can_rewrite_the_url_and_the_body() {
121        let request =
122            with_pre_request("request.url = request.url + \"?dry_run=1\";\nrequest.body = \"{}\";");
123        let sent = run_pre(&request).expect("the script should run");
124
125        assert_eq!(sent.url, "https://example.com/orders?dry_run=1");
126        assert_eq!(sent.body.as_deref(), Some("{}"));
127    }
128
129    #[test]
130    fn a_pre_request_script_can_clear_the_body() {
131        // `()` is "no body", which is a different thing from an empty one.
132        let request = with_pre_request("request.body = ();");
133        assert_eq!(run_pre(&request).expect("the script should run").body, None);
134
135        let request = with_pre_request(r#"request.body = "";"#);
136        assert_eq!(
137            run_pre(&request)
138                .expect("the script should run")
139                .body
140                .as_deref(),
141            Some("")
142        );
143    }
144
145    #[test]
146    fn a_pre_request_script_can_read_the_request_it_was_given() {
147        // Every field is readable, including the method it may not write.
148        let request = with_pre_request(
149            r#"request.headers["X-Seen"] = request.method + " " + request.url + " " + request.body.len();"#,
150        );
151        let sent = run_pre(&request).expect("the script should run");
152
153        assert_eq!(
154            sent.header("X-Seen"),
155            Some("POST https://example.com/orders 8")
156        );
157    }
158
159    #[test]
160    fn a_pre_request_script_that_throws_is_a_typed_error_not_a_panic() {
161        let request = with_pre_request(r#"throw "no signing key";"#);
162        let err = run_pre(&request).expect_err("a throw stops the request");
163
164        assert!(
165            matches!(
166                &err,
167                SendraError::ScriptFailed {
168                    hook: Hook::PreRequest,
169                    message
170                } if message == "no signing key"
171            ),
172            "{err:?}"
173        );
174    }
175
176    // --- post_request -----------------------------------------------------
177
178    #[test]
179    fn a_post_request_script_can_read_the_response_and_pass() {
180        let request = with_post_request(
181            "if response.status != 201 { throw \"expected 201\"; }\n\
182             if !response.body.contains(\"ada\") { throw \"expected ada\"; }\n\
183             if response.status_text != \"Created\" { throw \"expected Created\"; }\n\
184             if response.elapsed_ms < 0 { throw \"time ran backwards\"; }",
185        );
186
187        assert_eq!(run_post(&request, &response()), ScriptOutcome::Passed);
188    }
189
190    #[test]
191    fn a_post_request_script_sees_every_header_including_repeats() {
192        // The list shape earns its keep here: a map keyed by name would have
193        // dropped one of the two `set-cookie`s without a word.
194        let request = with_post_request(
195            "let cookies = response.headers.filter(|h| h.name == \"set-cookie\");\n\
196             if cookies.len() != 2 { throw \"expected 2 set-cookie headers, got \" + cookies.len(); }\n\
197             let ct = response.headers.find(|h| h.name == \"content-type\");\n\
198             if ct == () || !ct.value.contains(\"json\") { throw \"expected JSON\"; }",
199        );
200
201        assert_eq!(run_post(&request, &response()), ScriptOutcome::Passed);
202    }
203
204    #[test]
205    fn a_post_request_script_can_fail_explicitly() {
206        let request = with_post_request(
207            r#"if response.status != 200 { throw "expected 200, got " + response.status; }"#,
208        );
209
210        assert_eq!(
211            run_post(&request, &response()),
212            ScriptOutcome::Failed {
213                message: "expected 200, got 201".to_string()
214            }
215        );
216    }
217
218    #[test]
219    fn a_thrown_message_is_reported_verbatim() {
220        // Not wrapped in "Runtime error: … (line 1, position 1)": the sentence
221        // the author wrote is the thing to read.
222        let request = with_post_request(r#"throw "the order id was missing";"#);
223        let outcome = run_post(&request, &response());
224
225        assert_eq!(outcome.failure(), Some("the order id was missing"));
226        assert!(!outcome.passed());
227    }
228
229    #[test]
230    fn a_bug_in_a_post_request_script_keeps_its_position() {
231        // The other half of the wording rule: this is a mistake in the script,
232        // not a statement about the response, so the line number is the point.
233        let request = with_post_request("response.body.no_such_method();");
234        let outcome = run_post(&request, &response());
235
236        let message = outcome.failure().expect("a bug is still a failure");
237        assert!(message.contains("no_such_method"), "{message}");
238        assert!(message.contains("line"), "{message}");
239    }
240
241    #[test]
242    fn a_post_request_script_cannot_modify_the_response() {
243        // Pushed as a constant, so this is Rhai's own error rather than a
244        // mutation that goes nowhere.
245        let request = with_post_request("response.status = 200;");
246        let outcome = run_post(&request, &response());
247
248        assert!(!outcome.passed(), "assigning to the response must fail");
249        let message = outcome.failure().unwrap();
250        assert!(message.to_lowercase().contains("constant"), "{message}");
251    }
252}