1use crate::http::{HttpRequest, HttpResponse};
14use serde_json::Value;
15use std::collections::{BTreeMap, BTreeSet};
16
17#[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#[derive(Clone, Debug, Default)]
34pub(crate) struct WorkflowState {
35 pub inputs: Value,
36 pub outputs: BTreeMap<String, Value>,
37}
38
39#[derive(Clone, Debug, Default)]
41pub(crate) struct StepState {
42 pub exchange: Option<Exchange>,
43 pub outputs: BTreeMap<String, Value>,
44 pub passed: bool,
48}
49
50pub(crate) struct Scope<'a> {
52 pub inputs: &'a Value,
54 pub outputs: &'a BTreeMap<String, Value>,
56 pub steps: &'a BTreeMap<String, StepState>,
58 pub workflows: &'a BTreeMap<String, WorkflowState>,
60 pub self_: Option<&'a str>,
62 pub declared_steps: &'a BTreeSet<String>,
67 pub declared_workflows: &'a BTreeSet<String>,
69 pub here: Option<&'a Exchange>,
71 pub sources: &'a Value,
73 pub components: &'a Value,
75}
76
77#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
79pub enum ExpressionError {
80 #[error("`{0}` is not a runtime expression")]
82 Unknown(String),
83 #[error("`{expression}` names {what}")]
85 Missing {
86 expression: String,
88 what: String,
90 },
91 #[error("`{expression}` names {what}, which has not run")]
97 NotRun {
98 expression: String,
100 what: String,
102 },
103 #[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#[must_use]
125pub(crate) fn is_expression(text: &str) -> bool {
126 text.starts_with('$')
127}
128
129pub(crate) fn evaluate(expression: &str, scope: &Scope<'_>) -> Result<Value, ExpressionError> {
131 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 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 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 "$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
238fn 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
281fn 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
310fn 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
326fn 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
340pub(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
359pub(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
373pub(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 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 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 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 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 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 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 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 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}