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}