Skip to main content

roas_arazzo_executor/
expression.rs

1//! Arazzo runtime expressions.
2//!
3//! The model keeps these as plain strings — `roas-arazzo` parses the
4//! document, not the little language inside it — so this is where
5//! `$response.body#/id` becomes a value.
6//!
7//! Per [Runtime Expressions](https://spec.openapis.org/arazzo/v1.1.0.html#runtime-expressions).
8//! A name that resolves to nothing is an error rather than a null: a
9//! workflow that reads an output no step wrote is a workflow with a bug,
10//! and saying so where it happens beats a null surfacing three steps
11//! later.
12
13use crate::http::{HttpRequest, HttpResponse};
14use serde_json::Value;
15use std::collections::{BTreeMap, BTreeSet};
16
17/// A step's exchange: what was sent, and what came back.
18///
19/// The request is kept alongside the values that went into it, because
20/// `$request.path.id` asks about the value the step supplied, not about
21/// the URL it ended up inside.
22#[derive(Clone, Debug, Default)]
23pub(crate) struct Exchange {
24    pub request: HttpRequest,
25    pub path: BTreeMap<String, Value>,
26    pub query: BTreeMap<String, Value>,
27    pub body: Option<Value>,
28    pub response: Option<HttpResponse>,
29    pub response_body: Option<Value>,
30}
31
32/// What a workflow that has finished was given and produced.
33#[derive(Clone, Debug, Default)]
34pub(crate) struct WorkflowState {
35    pub inputs: Value,
36    pub outputs: BTreeMap<String, Value>,
37}
38
39/// What a step has produced so far.
40#[derive(Clone, Debug, Default)]
41pub(crate) struct StepState {
42    pub exchange: Option<Exchange>,
43    pub outputs: BTreeMap<String, Value>,
44    /// Whether it did what it said it would. A step that did not names
45    /// no outputs, which is a different thing from a description asking
46    /// for an output that does not exist.
47    pub passed: bool,
48}
49
50/// Everything a runtime expression can name at one point in a run.
51pub(crate) struct Scope<'a> {
52    /// The inputs the workflow was called with.
53    pub inputs: &'a Value,
54    /// The outputs the workflow has named so far.
55    pub outputs: &'a BTreeMap<String, Value>,
56    /// Every step of this workflow that has run.
57    pub steps: &'a BTreeMap<String, StepState>,
58    /// The workflows that have finished, by id.
59    pub workflows: &'a BTreeMap<String, WorkflowState>,
60    /// The description's `$self`, when it declares one.
61    pub self_: Option<&'a str>,
62    /// Every step id this workflow declares. A step that is named but
63    /// has not run is a different thing from a name that is no step of
64    /// this workflow at all — one is how a workflow that stopped early
65    /// looks, the other is a typo.
66    pub declared_steps: &'a BTreeSet<String>,
67    /// Every workflow id the description declares, for the same reason.
68    pub declared_workflows: &'a BTreeSet<String>,
69    /// The step being evaluated, when there is one.
70    pub here: Option<&'a Exchange>,
71    /// `sourceDescriptions`, by name.
72    pub sources: &'a Value,
73    /// The description's `components`.
74    pub components: &'a Value,
75}
76
77/// Why an expression could not be turned into a value.
78#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
79pub enum ExpressionError {
80    /// The expression does not start with a name this crate knows.
81    #[error("`{0}` is not a runtime expression")]
82    Unknown(String),
83    /// The expression is well formed but names something absent.
84    #[error("`{expression}` names {what}")]
85    Missing {
86        /// The expression as written.
87        expression: String,
88        /// What it wanted that was not there.
89        what: String,
90    },
91    /// The expression names a step or a workflow that has not run.
92    ///
93    /// Told apart from anything else missing because a workflow that
94    /// stopped early is *expected* to have steps that never ran, while
95    /// every other absence is a fault worth reporting.
96    #[error("`{expression}` names {what}, which has not run")]
97    NotRun {
98        /// The expression as written.
99        expression: String,
100        /// The step or workflow it named.
101        what: String,
102    },
103    /// The expression belongs to a part of Arazzo this crate does not
104    /// execute.
105    #[error("`{0}` belongs to an AsyncAPI step, which this crate does not execute")]
106    Unsupported(String),
107}
108
109fn not_run(expression: &str, what: impl Into<String>) -> ExpressionError {
110    ExpressionError::NotRun {
111        expression: expression.to_owned(),
112        what: what.into(),
113    }
114}
115
116fn missing(expression: &str, what: impl Into<String>) -> ExpressionError {
117    ExpressionError::Missing {
118        expression: expression.to_owned(),
119        what: what.into(),
120    }
121}
122
123/// Whether `text` is an expression rather than a literal.
124#[must_use]
125pub(crate) fn is_expression(text: &str) -> bool {
126    text.starts_with('$')
127}
128
129/// Evaluate one whole expression, e.g. `$response.body#/id`.
130pub(crate) fn evaluate(expression: &str, scope: &Scope<'_>) -> Result<Value, ExpressionError> {
131    // A `#` starts the JSON Pointer half; everything before it is the
132    // dotted name half.
133    let (name, pointer) = match expression.split_once('#') {
134        Some((name, pointer)) => (name, Some(pointer)),
135        None => (expression, None),
136    };
137    let mut parts = name.split('.');
138    let root = parts.next().unwrap_or_default();
139    let rest: Vec<&str> = parts.collect();
140
141    let value = match root {
142        "$inputs" => walk(scope.inputs, &rest, expression, "an input")?,
143        "$outputs" => from_map(scope.outputs, &rest, expression, "an output")?,
144        "$components" => walk(scope.components, &rest, expression, "a component")?,
145        "$sourceDescriptions" => walk(scope.sources, &rest, expression, "a source description")?,
146        "$self" => Value::String(
147            scope
148                .self_
149                .ok_or_else(|| missing(expression, "`$self`, which the description does not set"))?
150                .to_owned(),
151        ),
152        "$workflows" => {
153            let (id, rest) = split_first(&rest, expression, "a workflow id")?;
154            let Some(workflow) = scope.workflows.get(id) else {
155                return Err(if scope.declared_workflows.contains(id) {
156                    not_run(expression, format!("workflow `{id}`"))
157                } else {
158                    missing(
159                        expression,
160                        format!("workflow `{id}`, which this description has not got"),
161                    )
162                });
163            };
164            // `inputs` and `outputs` are both fields of a workflow; the
165            // bare shorthand names an output.
166            match rest.split_first() {
167                Some((field, rest)) if *field == "inputs" => {
168                    walk(&workflow.inputs, rest, expression, "an input")?
169                }
170                Some((field, rest)) if *field == "outputs" => {
171                    from_map(&workflow.outputs, rest, expression, "an output")?
172                }
173                _ => from_map(&workflow.outputs, rest, expression, "an output")?,
174            }
175        }
176        "$steps" => {
177            let (id, rest) = split_first(&rest, expression, "a step id")?;
178            let Some(step) = scope.steps.get(id) else {
179                return Err(if scope.declared_steps.contains(id) {
180                    not_run(expression, format!("step `{id}`"))
181                } else {
182                    missing(
183                        expression,
184                        format!("step `{id}`, which this workflow has not got"),
185                    )
186                });
187            };
188            if let Some(rest) = rest.strip_prefix(&["outputs"][..]) {
189                match from_map(&step.outputs, rest, expression, "an output") {
190                    // A step that failed named nothing, and saying it
191                    // has not produced this is the same kind of answer
192                    // as a step that never ran at all.
193                    Err(ExpressionError::Missing { .. }) if !step.passed => {
194                        return Err(not_run(
195                            expression,
196                            format!("an output of step `{id}`, which did not succeed"),
197                        ));
198                    }
199                    other => other?,
200                }
201            } else {
202                let exchange = step.exchange.as_ref().ok_or_else(|| {
203                    missing(
204                        expression,
205                        format!("the exchange of step `{id}`, which has none"),
206                    )
207                })?;
208                within(exchange, rest, expression)?
209            }
210        }
211        "$message" => return Err(ExpressionError::Unsupported(expression.to_owned())),
212        // The rest read the step being evaluated. A name that is none of
213        // them is not an expression at all, which is worth saying before
214        // asking whether the step has sent anything.
215        "$url" | "$method" | "$statusCode" | "$request" | "$response" => {
216            let exchange = scope.here.ok_or_else(|| {
217                missing(
218                    expression,
219                    "the current step, which has not sent anything yet",
220                )
221            })?;
222            let mut whole = vec![root];
223            whole.extend_from_slice(&rest);
224            within(exchange, &whole, expression)?
225        }
226        other => return Err(ExpressionError::Unknown(other.to_owned())),
227    };
228
229    match pointer {
230        None => Ok(value),
231        Some(pointer) => value
232            .pointer(pointer)
233            .cloned()
234            .ok_or_else(|| missing(expression, format!("`{pointer}`, which the value has not"))),
235    }
236}
237
238/// Read one exchange: `$url`, `$method`, `$statusCode`, `$request.*`,
239/// `$response.*` — with or without the leading `$`, so the same code
240/// serves `$steps.<id>.url`.
241fn within(exchange: &Exchange, parts: &[&str], expression: &str) -> Result<Value, ExpressionError> {
242    let (head, rest) = split_first(parts, expression, "a name")?;
243    let response = || {
244        exchange
245            .response
246            .as_ref()
247            .ok_or_else(|| missing(expression, "a response, which this step has not received"))
248    };
249    match head.trim_start_matches('$') {
250        "url" => Ok(Value::String(exchange.request.url.clone())),
251        "method" => Ok(Value::String(exchange.request.method.clone())),
252        "statusCode" => Ok(Value::from(response()?.status)),
253        "request" => {
254            let (what, rest) = split_first(rest, expression, "a request part")?;
255            match what {
256                "header" => header(&exchange.request.headers, rest, expression),
257                "path" => from_map(&exchange.path, rest, expression, "a path parameter"),
258                "query" => from_map(&exchange.query, rest, expression, "a query parameter"),
259                "body" => exchange
260                    .body
261                    .clone()
262                    .ok_or_else(|| missing(expression, "a request body, which this step has none")),
263                other => Err(ExpressionError::Unknown(format!("$request.{other}"))),
264            }
265        }
266        "response" => {
267            let (what, rest) = split_first(rest, expression, "a response part")?;
268            match what {
269                "header" => header(&response()?.headers, rest, expression),
270                "body" => exchange.response_body.clone().ok_or_else(|| {
271                    missing(expression, "a JSON response body, which this step has none")
272                }),
273                other => Err(ExpressionError::Unknown(format!("$response.{other}"))),
274            }
275        }
276        "message" => Err(ExpressionError::Unsupported(expression.to_owned())),
277        other => Err(ExpressionError::Unknown(format!("${other}"))),
278    }
279}
280
281/// A header value. Header names may hold dots, so the remainder is
282/// rejoined rather than taken one part at a time.
283fn header(
284    headers: &[(String, String)],
285    rest: &[&str],
286    expression: &str,
287) -> Result<Value, ExpressionError> {
288    let name = rest.join(".");
289    if name.is_empty() {
290        return Err(missing(expression, "a header name"));
291    }
292    headers
293        .iter()
294        .find(|(header, _)| header.eq_ignore_ascii_case(&name))
295        .map(|(_, value)| Value::String(value.clone()))
296        .ok_or_else(|| missing(expression, format!("header `{name}`, which is not present")))
297}
298
299fn split_first<'p>(
300    parts: &'p [&'p str],
301    expression: &str,
302    what: &str,
303) -> Result<(&'p str, &'p [&'p str]), ExpressionError> {
304    match parts.split_first() {
305        Some((head, rest)) if !head.is_empty() => Ok((head, rest)),
306        _ => Err(missing(expression, format!("{what}, which is missing"))),
307    }
308}
309
310/// Walk a dotted name into a JSON value.
311fn walk(
312    value: &Value,
313    parts: &[&str],
314    expression: &str,
315    what: &str,
316) -> Result<Value, ExpressionError> {
317    let mut current = value;
318    for part in parts {
319        current = current
320            .get(part)
321            .ok_or_else(|| missing(expression, format!("{what} named `{part}`")))?;
322    }
323    Ok(current.clone())
324}
325
326/// The same, for the maps the run keeps by name.
327fn from_map(
328    map: &BTreeMap<String, Value>,
329    parts: &[&str],
330    expression: &str,
331    what: &str,
332) -> Result<Value, ExpressionError> {
333    let (name, rest) = split_first(parts, expression, what)?;
334    let value = map
335        .get(name)
336        .ok_or_else(|| missing(expression, format!("{what} named `{name}`")))?;
337    walk(value, rest, expression, what)
338}
339
340/// Every `{$…}` a string fills in.
341///
342/// This is all that happens to a string a caller only ever
343/// interpolates — a `regex` or `jsonpath` condition, say. An unbraced
344/// `$steps.b.outputs.x` in a pattern is pattern text, and matched as
345/// such.
346pub(crate) fn interpolations(text: &str) -> Vec<&str> {
347    let mut found = Vec::new();
348    let mut at = 0;
349    while let Some(start) = text[at..].find("{$").map(|start| at + start) {
350        let Some(end) = text[start..].find('}').map(|end| start + end) else {
351            break;
352        };
353        found.push(&text[start + 1..end]);
354        at = end + 1;
355    }
356    found
357}
358
359/// Every runtime expression a *value* really evaluates: the whole
360/// string where the whole of it is one, and every `{$…}` inside it.
361///
362/// This is what tells a reference from a mention. `"see
363/// $steps.a.outputs.x"` is a sentence, and goes on the wire as one.
364pub(crate) fn references(text: &str) -> Vec<&str> {
365    let mut found = Vec::new();
366    if is_expression(text) {
367        found.push(text);
368    }
369    found.extend(interpolations(text));
370    found
371}
372
373/// Replace every `{$…}` in `text` with what it evaluates to.
374///
375/// A string is what the caller asked for, so a string value is put in as
376/// it stands and anything else as its JSON.
377pub(crate) fn interpolate(text: &str, scope: &Scope<'_>) -> Result<String, ExpressionError> {
378    let mut out = String::with_capacity(text.len());
379    let mut rest = text;
380    while let Some(start) = rest.find("{$") {
381        let Some(end) = rest[start..].find('}').map(|end| start + end) else {
382            break;
383        };
384        out.push_str(&rest[..start]);
385        let value = evaluate(&rest[start + 1..end], scope)?;
386        match value {
387            Value::String(text) => out.push_str(&text),
388            other => out.push_str(&other.to_string()),
389        }
390        rest = &rest[end + 1..];
391    }
392    out.push_str(rest);
393    Ok(out)
394}
395
396#[cfg(test)]
397pub(crate) mod tests {
398    use super::*;
399    use serde_json::json;
400
401    /// A scope holding everything the tests name, so each test says only
402    /// what it is about.
403    pub(crate) struct Fixture {
404        pub inputs: Value,
405        pub outputs: BTreeMap<String, Value>,
406        pub steps: BTreeMap<String, StepState>,
407        pub workflows: BTreeMap<String, WorkflowState>,
408        pub self_: Option<String>,
409        pub here: Option<Exchange>,
410        pub sources: Value,
411        pub components: Value,
412        pub declared_steps: BTreeSet<String>,
413        pub declared_workflows: BTreeSet<String>,
414    }
415
416    impl Default for Fixture {
417        fn default() -> Self {
418            Self {
419                inputs: json!({ "petId": "7", "auth": { "token": "abc" } }),
420                outputs: BTreeMap::new(),
421                steps: BTreeMap::new(),
422                workflows: BTreeMap::new(),
423                self_: None,
424                here: None,
425                sources: json!({ "petStore": { "url": "https://api.example.com/openapi.json" } }),
426                components: json!({ "parameters": { "locale": { "name": "locale" } } }),
427                // Every id the tests name, so a name that is absent is
428                // a step that has not run rather than a typo.
429                declared_steps: ["findPet", "call", "failed", "nope"]
430                    .into_iter()
431                    .map(ToOwned::to_owned)
432                    .collect(),
433                declared_workflows: ["authenticate", "nope"]
434                    .into_iter()
435                    .map(ToOwned::to_owned)
436                    .collect(),
437            }
438        }
439    }
440
441    impl Fixture {
442        pub(crate) fn scope(&self) -> Scope<'_> {
443            Scope {
444                inputs: &self.inputs,
445                outputs: &self.outputs,
446                steps: &self.steps,
447                workflows: &self.workflows,
448                self_: self.self_.as_deref(),
449                here: self.here.as_ref(),
450                sources: &self.sources,
451                components: &self.components,
452                declared_steps: &self.declared_steps,
453                declared_workflows: &self.declared_workflows,
454            }
455        }
456    }
457
458    pub(crate) fn exchange() -> Exchange {
459        Exchange {
460            request: HttpRequest {
461                method: "GET".to_owned(),
462                url: "https://api.example.com/pets/7".to_owned(),
463                headers: vec![("Authorization".to_owned(), "Bearer abc".to_owned())],
464                body: None,
465                timeout: None,
466            },
467            path: BTreeMap::from([("petId".to_owned(), json!("7"))]),
468            query: BTreeMap::from([("limit".to_owned(), json!(10))]),
469            body: Some(json!({ "name": "fluffy" })),
470            response: Some(HttpResponse {
471                status: 200,
472                headers: vec![("X-Rate-Limit".to_owned(), "9".to_owned())],
473                body: br#"{"id":7,"tags":["cat"]}"#.to_vec(),
474            }),
475            response_body: Some(json!({ "id": 7, "tags": ["cat"] })),
476        }
477    }
478
479    fn eval(expression: &str, fixture: &Fixture) -> Result<Value, ExpressionError> {
480        evaluate(expression, &fixture.scope())
481    }
482
483    #[test]
484    fn the_document_and_its_inputs_are_readable() {
485        let fixture = Fixture::default();
486        assert_eq!(eval("$inputs.petId", &fixture), Ok(json!("7")));
487        assert_eq!(eval("$inputs.auth.token", &fixture), Ok(json!("abc")));
488        assert_eq!(
489            eval("$sourceDescriptions.petStore.url", &fixture),
490            Ok(json!("https://api.example.com/openapi.json"))
491        );
492        assert_eq!(
493            eval("$components.parameters.locale.name", &fixture),
494            Ok(json!("locale"))
495        );
496    }
497
498    #[test]
499    fn the_current_exchange_is_readable_every_way_the_spec_spells_it() {
500        let fixture = Fixture {
501            here: Some(exchange()),
502            ..Fixture::default()
503        };
504        assert_eq!(
505            eval("$url", &fixture),
506            Ok(json!("https://api.example.com/pets/7"))
507        );
508        assert_eq!(eval("$method", &fixture), Ok(json!("GET")));
509        assert_eq!(eval("$statusCode", &fixture), Ok(json!(200)));
510        assert_eq!(
511            eval("$request.header.authorization", &fixture),
512            Ok(json!("Bearer abc"))
513        );
514        assert_eq!(eval("$request.path.petId", &fixture), Ok(json!("7")));
515        assert_eq!(eval("$request.query.limit", &fixture), Ok(json!(10)));
516        assert_eq!(
517            eval("$request.body", &fixture),
518            Ok(json!({ "name": "fluffy" }))
519        );
520        assert_eq!(
521            eval("$response.header.X-Rate-Limit", &fixture),
522            Ok(json!("9"))
523        );
524        assert_eq!(
525            eval("$response.body", &fixture),
526            Ok(json!({ "id": 7, "tags": ["cat"] }))
527        );
528    }
529
530    #[test]
531    fn a_pointer_reaches_into_whatever_the_name_produced() {
532        let fixture = Fixture {
533            here: Some(exchange()),
534            ..Fixture::default()
535        };
536        assert_eq!(eval("$response.body#/id", &fixture), Ok(json!(7)));
537        assert_eq!(eval("$response.body#/tags/0", &fixture), Ok(json!("cat")));
538        assert_eq!(eval("$request.body#/name", &fixture), Ok(json!("fluffy")));
539        assert!(matches!(
540            eval("$response.body#/nope", &fixture),
541            Err(ExpressionError::Missing { .. })
542        ));
543    }
544
545    #[test]
546    fn an_earlier_step_is_readable_by_id() {
547        let mut steps = BTreeMap::new();
548        steps.insert(
549            "findPet".to_owned(),
550            StepState {
551                exchange: Some(exchange()),
552                outputs: BTreeMap::from([("pet".to_owned(), json!({ "id": 7 }))]),
553                passed: true,
554            },
555        );
556        let fixture = Fixture {
557            steps,
558            ..Fixture::default()
559        };
560        assert_eq!(
561            eval("$steps.findPet.outputs.pet", &fixture),
562            Ok(json!({ "id": 7 }))
563        );
564        assert_eq!(
565            eval("$steps.findPet.outputs.pet#/id", &fixture),
566            Ok(json!(7))
567        );
568        assert_eq!(eval("$steps.findPet.statusCode", &fixture), Ok(json!(200)));
569        assert_eq!(
570            eval("$steps.findPet.response.body#/id", &fixture),
571            Ok(json!(7))
572        );
573        assert!(matches!(
574            eval("$steps.nope.outputs.pet", &fixture),
575            Err(ExpressionError::NotRun { .. })
576        ));
577        // A step that ran but did not succeed named nothing, which is
578        // the same kind of answer — and a different one from asking a
579        // step that succeeded for an output it does not have.
580        let mut steps = fixture.steps.clone();
581        steps.insert(
582            "failed".to_owned(),
583            StepState {
584                exchange: Some(exchange()),
585                outputs: BTreeMap::new(),
586                passed: false,
587            },
588        );
589        let after = Fixture {
590            steps,
591            ..Fixture::default()
592        };
593        assert!(matches!(
594            eval("$steps.failed.outputs.pet", &after),
595            Err(ExpressionError::NotRun { .. })
596        ));
597        assert!(matches!(
598            eval("$steps.findPet.outputs.nope", &after),
599            Err(ExpressionError::Missing { .. })
600        ));
601    }
602
603    #[test]
604    fn a_finished_workflow_is_readable_by_field_and_by_shorthand() {
605        let workflows = BTreeMap::from([(
606            "authenticate".to_owned(),
607            WorkflowState {
608                inputs: json!({ "user": "ada" }),
609                outputs: BTreeMap::from([("token".to_owned(), json!("abc"))]),
610            },
611        )]);
612        let fixture = Fixture {
613            workflows,
614            ..Fixture::default()
615        };
616        assert_eq!(
617            eval("$workflows.authenticate.outputs.token", &fixture),
618            Ok(json!("abc"))
619        );
620        // What it was called with is readable too, which is a field of
621        // its own rather than another way of naming an output.
622        assert_eq!(
623            eval("$workflows.authenticate.inputs.user", &fixture),
624            Ok(json!("ada"))
625        );
626        assert_eq!(
627            eval("$workflows.authenticate.token", &fixture),
628            Ok(json!("abc")),
629            "the shorthand names an output"
630        );
631        assert!(matches!(
632            eval("$workflows.authenticate.inputs.nope", &fixture),
633            Err(ExpressionError::Missing { .. })
634        ));
635    }
636
637    #[test]
638    fn the_description_can_name_itself() {
639        let fixture = Fixture {
640            self_: Some("https://example.com/workflows.arazzo.yaml".to_owned()),
641            ..Fixture::default()
642        };
643        assert_eq!(
644            eval("$self", &fixture),
645            Ok(json!("https://example.com/workflows.arazzo.yaml"))
646        );
647        // A description that sets no `$self` says so rather than
648        // producing an empty string.
649        assert!(matches!(
650            eval("$self", &Fixture::default()),
651            Err(ExpressionError::Missing { .. })
652        ));
653    }
654
655    #[test]
656    fn what_is_not_there_says_so_rather_than_reading_as_null() {
657        let fixture = Fixture::default();
658        assert_eq!(
659            eval("$inputs.nope", &fixture).unwrap_err().to_string(),
660            "`$inputs.nope` names an input named `nope`"
661        );
662        // No exchange yet: the step has sent nothing.
663        assert!(matches!(
664            eval("$statusCode", &fixture),
665            Err(ExpressionError::Missing { .. })
666        ));
667        assert_eq!(
668            eval("$nonsense.x", &fixture),
669            Err(ExpressionError::Unknown("$nonsense".to_owned()))
670        );
671        assert!(matches!(
672            eval("$message.payload", &fixture),
673            Err(ExpressionError::Unsupported(_))
674        ));
675    }
676
677    #[test]
678    fn a_response_that_has_not_arrived_is_not_a_status_code() {
679        let fixture = Fixture {
680            here: Some(Exchange {
681                response: None,
682                response_body: None,
683                ..exchange()
684            }),
685            ..Fixture::default()
686        };
687        assert!(matches!(
688            eval("$statusCode", &fixture),
689            Err(ExpressionError::Missing { .. })
690        ));
691        assert!(matches!(
692            eval("$response.body", &fixture),
693            Err(ExpressionError::Missing { .. })
694        ));
695        assert!(matches!(
696            eval("$response.header.x", &fixture),
697            Err(ExpressionError::Missing { .. })
698        ));
699    }
700
701    #[test]
702    fn naming_a_workflow_or_a_steps_exchange_that_is_not_there_says_which() {
703        let mut steps = BTreeMap::new();
704        // A step that called a workflow has outputs but no exchange.
705        steps.insert(
706            "call".to_owned(),
707            StepState {
708                exchange: None,
709                outputs: BTreeMap::from([("token".to_owned(), json!("abc"))]),
710                passed: true,
711            },
712        );
713        let fixture = Fixture {
714            steps,
715            ..Fixture::default()
716        };
717        assert_eq!(
718            eval("$workflows.nope.outputs.token", &fixture)
719                .unwrap_err()
720                .to_string(),
721            "`$workflows.nope.outputs.token` names workflow `nope`, which has not run"
722        );
723        assert_eq!(
724            eval("$steps.call.outputs.token", &fixture),
725            Ok(json!("abc"))
726        );
727        assert_eq!(
728            eval("$steps.call.statusCode", &fixture)
729                .unwrap_err()
730                .to_string(),
731            "`$steps.call.statusCode` names the exchange of step `call`, which has none"
732        );
733    }
734
735    #[test]
736    fn a_string_carries_expressions_inside_it() {
737        let fixture = Fixture {
738            here: Some(exchange()),
739            ..Fixture::default()
740        };
741        let scope = fixture.scope();
742        assert_eq!(
743            interpolate("Bearer {$inputs.auth.token}", &scope),
744            Ok("Bearer abc".to_owned())
745        );
746        assert_eq!(
747            interpolate(
748                "/pets/{$inputs.petId}/tags/{$response.body#/tags/0}",
749                &scope
750            ),
751            Ok("/pets/7/tags/cat".to_owned())
752        );
753        // A number goes in as its JSON, and text with no expression is
754        // left exactly as it was.
755        assert_eq!(
756            interpolate("n={$request.query.limit}", &scope),
757            Ok("n=10".to_owned())
758        );
759        assert_eq!(
760            interpolate("nothing here", &scope),
761            Ok("nothing here".to_owned())
762        );
763        assert_eq!(
764            interpolate("unclosed {$inputs.petId", &scope),
765            Ok("unclosed {$inputs.petId".to_owned())
766        );
767    }
768
769    #[test]
770    fn a_literal_is_told_apart_from_an_expression() {
771        assert!(is_expression("$inputs.x"));
772        assert!(!is_expression("plain"));
773    }
774}