Skip to main content

sendra_core/script/
mod.rs

1//! The `pre_request` and `post_request` hooks: compiling them, running them,
2//! and the closed surface a script can see.
3//!
4//! # What a script is
5//!
6//! A script is Rhai source, written inline in the request file as a YAML block
7//! scalar:
8//!
9//! ```text
10//! method: POST
11//! url: https://api.example.com/orders
12//! pre_request: |
13//!   request.headers["X-Request-Id"] = "abc-123";
14//! post_request: |
15//!   if response.status != 201 {
16//!     throw "expected 201, got " + response.status;
17//!   }
18//! ```
19//!
20//! [Rhai] rather than an embedded JavaScript engine because the whole point of
21//! the feature is that a script needs nothing installed next to `sendra`: the
22//! interpreter is linked into the binary, there is no FFI boundary, and the
23//! sandbox is a property of what the [`Engine`](rhai::Engine) was built with
24//! rather than of a separate runtime's flags.
25//!
26//! # Script source is never substituted
27//!
28//! **A `{{variable}}` or `${OS_VAR}` inside a script is not expanded.** It is
29//! whatever those characters mean to Rhai — in practice, part of a string
30//! literal. [`Environment::apply`] copies both script fields through verbatim,
31//! and there is a test on exactly that.
32//!
33//! This is a decision, not an oversight. Substitution is textual, and the whole
34//! reason it is confined to values is that a value must not be able to change
35//! the structure of the document it sits in. A script *is* structure: it is
36//! executable code, so the failure mode is not a malformed URL but a variable
37//! whose contents get parsed as program text. A script that needs an
38//! environment value reads it off the request it is handed — `request.url` and
39//! `request.headers` arrive fully substituted — which is both safe and the
40//! honest place for it to come from.
41//!
42//! # Ordering
43//!
44//! Fixed, and stated here because it decides what an existing file means:
45//!
46//! 1. Environment substitution.
47//! 2. Config apply.
48//! 3. `pre_request`, against the fully-substituted, config-applied request. It
49//!    is the last thing to touch the request before it goes over the wire, so
50//!    a header it removes stays removed — which is why the CLI applies the
51//!    config itself and then calls [`send_prepared`](crate::send_prepared)
52//!    rather than [`send`](crate::send), whose whole job is to apply it.
53//! 4. Send.
54//! 5. `post_request`, against the response.
55//! 6. Assertions, against the same response, unaffected by whether a
56//!    `post_request` script ran or what it decided.
57//!
58//! Scripts and assertions are two independent mechanisms that happen to look at
59//! the same response. Neither can see the other.
60//!
61//! # Both scripts are compiled before the request is sent
62//!
63//! [`Scripts::compile`] compiles `pre_request` *and* `post_request` up front,
64//! so a syntax error in a `post_request` script is found before the `POST` that
65//! would have created an order — not after. A file whose script does not parse
66//! is a broken file in the same way a collection with two identically-named
67//! requests is a broken file, and [`Collection`](crate::Collection) already
68//! makes the argument: finding that out before the first request goes over the
69//! wire beats finding it out halfway through a run.
70//!
71//! It is compiled per request rather than for the whole file, in the same place
72//! and for the same reason substitution is per request: a script that will not
73//! compile is that request's problem, not its siblings'.
74//!
75//! [Rhai]: https://rhai.rs
76//! [`Environment::apply`]: crate::Environment::apply
77
78use rhai::AST;
79
80use crate::{Request, SendraError};
81
82mod engine;
83mod marshal;
84mod run;
85
86#[cfg(test)]
87mod test_support;
88
89pub use run::{run_post_request, run_pre_request};
90
91/// Which of the two hooks a script is.
92///
93/// Carried on the error variants so a message can name the field the user has
94/// to go and fix, in the spelling they wrote it in.
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum Hook {
97    PreRequest,
98    PostRequest,
99}
100
101impl Hook {
102    /// The YAML key this hook is written as.
103    pub fn as_str(self) -> &'static str {
104        match self {
105            Hook::PreRequest => "pre_request",
106            Hook::PostRequest => "post_request",
107        }
108    }
109}
110
111impl std::fmt::Display for Hook {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        f.write_str(self.as_str())
114    }
115}
116
117/// A script that has been parsed and is ready to run.
118///
119/// Compiling is separated from running because the two failures are different
120/// problems for a user to fix and happen at different points in the pipeline:
121/// a script that does not parse is a broken file, found before anything is
122/// sent, while a script that parses and then throws is a statement about this
123/// particular request or response.
124#[derive(Debug, Clone)]
125pub struct Script {
126    hook: Hook,
127    ast: AST,
128}
129
130impl Script {
131    /// Parse `source` as the given hook.
132    pub fn compile(hook: Hook, source: &str) -> Result<Self, SendraError> {
133        let ast = engine::ENGINE
134            .with(|engine| engine.compile(source))
135            .map_err(|source| SendraError::ScriptParse { hook, source })?;
136
137        Ok(Self { hook, ast })
138    }
139
140    pub fn hook(&self) -> Hook {
141        self.hook
142    }
143}
144
145/// A request's two scripts, both compiled.
146///
147/// One type rather than two `Option<Script>` at the call site so that "compile
148/// everything before sending anything" is a single call that cannot be
149/// half-made, and so the CLI does not have to remember which order to compile
150/// them in.
151#[derive(Debug, Clone, Default)]
152pub struct Scripts {
153    pre_request: Option<Script>,
154    post_request: Option<Script>,
155}
156
157impl Scripts {
158    /// Compile whichever of `request`'s two script fields are present.
159    ///
160    /// `pre_request` is compiled first, so a file with two broken scripts
161    /// reports the one that would have run first.
162    pub fn compile(request: &Request) -> Result<Self, SendraError> {
163        Ok(Self {
164            pre_request: request
165                .pre_request
166                .as_deref()
167                .map(|source| Script::compile(Hook::PreRequest, source))
168                .transpose()?,
169            post_request: request
170                .post_request
171                .as_deref()
172                .map(|source| Script::compile(Hook::PostRequest, source))
173                .transpose()?,
174        })
175    }
176
177    pub fn pre_request(&self) -> Option<&Script> {
178        self.pre_request.as_ref()
179    }
180
181    pub fn post_request(&self) -> Option<&Script> {
182        self.post_request.as_ref()
183    }
184}
185
186/// Anything a script printed while it ran, in the order it printed it.
187///
188/// Rhai's `print` and `debug` write somewhere, and the only question is where.
189/// Core does not answer it: it collects the lines and hands them back, and the
190/// front-end decides what a line is for — stderr for the CLI, a pane for a TUI,
191/// a log record for something else. That is the same arrangement as every other
192/// piece of "what does the outside world do here" in this crate: `Config` and
193/// `Environment` take the directory to search rather than reading the real one,
194/// and the CLI's sending loop takes the function that sends rather than calling
195/// the network itself.
196///
197/// It matters more here than it looks. `sendra-core` has no `println!` or
198/// `eprintln!` anywhere, by design, because a `sendra-tui` sharing this crate
199/// cannot have a library writing over its interface — and a `print` in a script
200/// is exactly the kind of thing that would otherwise land in the middle of a
201/// redrawn frame, or inside the single JSON document `--json` promises stdout
202/// holds.
203///
204/// A `debug` line is already formatted with its source and position by the time
205/// it lands here, because that formatting is Rhai's information to render and
206/// not the front-end's to reconstruct. Which stream it goes to, and whether it
207/// is coloured, is the front-end's.
208#[derive(Debug, Clone, Default, PartialEq, Eq)]
209pub struct ScriptOutput {
210    lines: Vec<String>,
211}
212
213impl ScriptOutput {
214    /// The lines, in the order the script printed them.
215    pub fn lines(&self) -> &[String] {
216        &self.lines
217    }
218
219    /// Whether the script printed nothing — the usual case, and the one a
220    /// front-end should be able to check without allocating.
221    pub fn is_empty(&self) -> bool {
222        self.lines.is_empty()
223    }
224}
225
226/// What a `post_request` script decided about a response.
227///
228/// Not a `Result<(), SendraError>`, because neither outcome is an error in the
229/// sense the rest of this crate uses the word: the response came back, and the
230/// script is a check on it, exactly as an assertion is. A script that throws
231/// has *worked* — it has reported that the response was not what the file
232/// expected — and the front-end that receives this treats it the way it treats
233/// a failed assertion. See the CLI's `exit` module for where that lands in a
234/// summary and an exit code.
235#[derive(Debug, Clone, PartialEq, Eq)]
236pub enum ScriptOutcome {
237    /// The script ran to completion without throwing.
238    Passed,
239
240    /// The script threw, or hit a runtime error.
241    ///
242    /// The two are not told apart here; see [`failure_message`](engine::failure_message)
243    /// for why, and for what the string contains in each case.
244    Failed { message: String },
245}
246
247impl ScriptOutcome {
248    pub fn passed(&self) -> bool {
249        matches!(self, ScriptOutcome::Passed)
250    }
251
252    /// Why the script failed, or `None` if it did not.
253    pub fn failure(&self) -> Option<&str> {
254        match self {
255            ScriptOutcome::Passed => None,
256            ScriptOutcome::Failed { message } => Some(message),
257        }
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    use test_support::{request, with_pre_request};
266
267    // --- compiling --------------------------------------------------------
268
269    #[test]
270    fn a_syntax_error_is_a_compile_error_not_a_runtime_one() {
271        let request = with_pre_request("request.url = ;");
272        let err = Scripts::compile(&request).expect_err("broken syntax should not compile");
273
274        assert!(
275            matches!(
276                err,
277                SendraError::ScriptParse {
278                    hook: Hook::PreRequest,
279                    ..
280                }
281            ),
282            "{err:?}"
283        );
284    }
285
286    #[test]
287    fn a_post_request_syntax_error_is_found_before_the_request_is_sent() {
288        // The reason both hooks are compiled together: the `POST` that would
289        // have created an order never happens because the *check* on it does
290        // not parse.
291        let request = request(
292            "method: POST\nurl: https://example.com/orders\npost_request: |\n  if response.status { \n",
293        );
294        let err = Scripts::compile(&request).expect_err("broken syntax should not compile");
295
296        assert!(
297            matches!(
298                err,
299                SendraError::ScriptParse {
300                    hook: Hook::PostRequest,
301                    ..
302                }
303            ),
304            "{err:?}"
305        );
306    }
307
308    #[test]
309    fn a_request_with_no_scripts_compiles_to_nothing() {
310        let request = request("method: GET\nurl: https://example.com\n");
311        let scripts = Scripts::compile(&request).expect("nothing to compile");
312
313        assert!(scripts.pre_request().is_none());
314        assert!(scripts.post_request().is_none());
315    }
316
317    #[test]
318    fn the_first_broken_script_is_the_one_reported() {
319        // Both broken: the one that would have run first is the one to fix
320        // first.
321        let request = request(
322            "method: GET\nurl: https://example.com\npre_request: |\n  ) (\npost_request: |\n  ) (\n",
323        );
324        let err = Scripts::compile(&request).expect_err("neither script compiles");
325
326        assert!(
327            matches!(
328                err,
329                SendraError::ScriptParse {
330                    hook: Hook::PreRequest,
331                    ..
332                }
333            ),
334            "{err:?}"
335        );
336    }
337}