Skip to main content

roas_arazzo_executor/
report.rs

1//! What a run did, and what can stop one.
2//!
3//! The split matters: a step whose criteria do not hold is an *outcome*,
4//! not an error — the workflow said what to do about it. An error is
5//! something the run could not answer at all, like an operation no
6//! description holds.
7
8use crate::criterion::CriterionError;
9use crate::expression::ExpressionError;
10use crate::http::ClientError;
11use crate::operation::OperationError;
12use crate::select::SelectError;
13use serde_json::Value;
14use std::collections::BTreeMap;
15use std::fmt;
16use std::time::Duration;
17
18/// How a workflow finished.
19#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
20#[non_exhaustive]
21pub enum Outcome {
22    /// Every step that ran met its criteria.
23    #[default]
24    Succeeded,
25    /// A step failed and nothing said to carry on.
26    Failed,
27    /// An action ended the workflow before its last step.
28    Ended,
29}
30
31impl fmt::Display for Outcome {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        f.write_str(match self {
34            Outcome::Succeeded => "succeeded",
35            Outcome::Failed => "failed",
36            Outcome::Ended => "ended early",
37        })
38    }
39}
40
41/// One criterion, and whether it held.
42#[derive(Clone, Debug, PartialEq, Eq)]
43#[non_exhaustive]
44pub struct CriterionOutcome {
45    /// The condition as the description wrote it.
46    pub condition: String,
47    /// Whether it held.
48    pub passed: bool,
49}
50
51/// What a step did — Arazzo has two kinds, and they leave different
52/// traces.
53#[derive(Clone, Debug, PartialEq, Eq)]
54#[non_exhaustive]
55pub enum Performed {
56    /// The step called an API operation.
57    Request {
58        /// The method sent.
59        method: String,
60        /// The URL sent to.
61        url: String,
62        /// The status received.
63        status: u16,
64    },
65    /// The step called another workflow.
66    Workflow {
67        /// The workflow it called.
68        workflow_id: String,
69        /// How that workflow finished.
70        outcome: Outcome,
71    },
72}
73
74/// One attempt at one step.
75#[derive(Clone, Debug, PartialEq, Eq)]
76#[non_exhaustive]
77pub struct StepRecord {
78    /// The workflow the step belongs to.
79    pub workflow_id: String,
80    /// The step's id.
81    pub step_id: String,
82    /// 1 for the first try, 2 for the first retry, and so on.
83    pub attempt: u32,
84    /// What the step did.
85    pub performed: Performed,
86    /// Each success criterion, in the order the step lists them.
87    pub criteria: Vec<CriterionOutcome>,
88    /// Whether the step, as a whole, succeeded.
89    pub passed: bool,
90    /// The outputs the step named.
91    pub outputs: BTreeMap<String, Value>,
92    /// The action the step's outcome triggered, if any.
93    pub action: Option<String>,
94    /// How long it took.
95    pub elapsed: Duration,
96}
97
98impl StepRecord {
99    /// The status the step's request came back with, for a step that
100    /// sent one.
101    #[must_use]
102    pub fn status(&self) -> Option<u16> {
103        match &self.performed {
104            Performed::Request { status, .. } => Some(*status),
105            Performed::Workflow { .. } => None,
106        }
107    }
108
109    /// The method the step sent, for a step that sent a request.
110    #[must_use]
111    pub fn method(&self) -> Option<&str> {
112        match &self.performed {
113            Performed::Request { method, .. } => Some(method),
114            Performed::Workflow { .. } => None,
115        }
116    }
117
118    /// The URL the step sent to, for a step that sent a request.
119    #[must_use]
120    pub fn url(&self) -> Option<&str> {
121        match &self.performed {
122            Performed::Request { url, .. } => Some(url),
123            Performed::Workflow { .. } => None,
124        }
125    }
126}
127
128/// What a run did.
129#[derive(Clone, Debug, Default, PartialEq, Eq)]
130#[non_exhaustive]
131pub struct ExecutionReport {
132    /// The workflow that was asked for.
133    pub workflow_id: String,
134    /// How it finished.
135    pub outcome: Outcome,
136    /// The outputs it named.
137    pub outputs: BTreeMap<String, Value>,
138    /// Every attempt at every step, in the order they were made —
139    /// including the steps of workflows this one depended on or called.
140    pub steps: Vec<StepRecord>,
141}
142
143impl ExecutionReport {
144    /// Whether the workflow ran to a successful end.
145    #[must_use]
146    pub fn is_success(&self) -> bool {
147        self.outcome != Outcome::Failed
148    }
149}
150
151impl fmt::Display for ExecutionReport {
152    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153        writeln!(f, "workflow `{}` {}", self.workflow_id, self.outcome)?;
154        for step in &self.steps {
155            match &step.performed {
156                Performed::Request {
157                    method,
158                    url,
159                    status,
160                } => write!(f, "- {} {method} {url} → {status}", step.step_id)?,
161                Performed::Workflow {
162                    workflow_id,
163                    outcome,
164                } => write!(f, "- {} → workflow `{workflow_id}` {outcome}", step.step_id)?,
165            }
166            if step.attempt > 1 {
167                write!(f, " (attempt {})", step.attempt)?;
168            }
169            if !step.passed {
170                write!(f, " — failed")?;
171            }
172            if let Some(action) = &step.action {
173                write!(f, " — {action}")?;
174            }
175            writeln!(f)?;
176        }
177        for (name, value) in &self.outputs {
178            writeln!(f, "  {name} = {value}")?;
179        }
180        Ok(())
181    }
182}
183
184/// Why a run could not continue.
185#[derive(Debug, thiserror::Error)]
186#[non_exhaustive]
187pub enum ExecutionError {
188    /// The description holds no workflow by that name.
189    #[error("the description has no workflow `{0}`")]
190    UnknownWorkflow(String),
191    /// A `goto` named a step the workflow does not have.
192    #[error("workflow `{workflow}` has no step `{step}` to go to")]
193    UnknownStep {
194        /// The workflow the `goto` was in.
195        workflow: String,
196        /// The step it named.
197        step: String,
198    },
199    /// `dependsOn` describes a circle.
200    #[error("`dependsOn` is circular: {0}")]
201    Circular(String),
202    /// The step's operation could not be found.
203    #[error(transparent)]
204    Operation(#[from] OperationError),
205    /// A runtime expression could not be evaluated.
206    #[error(transparent)]
207    Expression(#[from] ExpressionError),
208    /// A value or selector could not be resolved.
209    #[error(transparent)]
210    Select(#[from] SelectError),
211    /// A criterion could not be decided.
212    #[error(transparent)]
213    Criterion(#[from] CriterionError),
214    /// The client could not carry a request out.
215    #[error("the request could not be sent: {0}")]
216    Client(#[from] ClientError),
217    /// The request could not be assembled.
218    #[error("step `{step}` cannot be turned into a request: {reason}")]
219    BadRequest {
220        /// The step being built.
221        step: String,
222        /// What went wrong.
223        reason: String,
224    },
225    /// A limit stopped the run — most likely a loop.
226    #[error("the run stopped after reaching its {limit} limit of {at}")]
227    Limit {
228        /// Which limit.
229        limit: &'static str,
230        /// What it was set to.
231        at: usize,
232    },
233    /// Something Arazzo allows that this crate does not execute.
234    #[error("{0}")]
235    Unsupported(String),
236    /// `supply` was called when no request was outstanding.
237    #[error("a response arrived when no request was outstanding")]
238    NotWaiting,
239    /// `advance` was called again before the outstanding request was
240    /// answered.
241    #[error("the run is waiting for a response to `{method} {url}` — supply it before advancing")]
242    Awaiting {
243        /// The method of the request still outstanding.
244        method: String,
245        /// Its URL.
246        url: String,
247    },
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use serde_json::json;
254
255    fn record(step_id: &str, status: u16, passed: bool) -> StepRecord {
256        StepRecord {
257            workflow_id: "buyPet".to_owned(),
258            step_id: step_id.to_owned(),
259            attempt: 1,
260            performed: Performed::Request {
261                method: "GET".to_owned(),
262                url: format!("https://api.example.com/{step_id}"),
263                status,
264            },
265            criteria: vec![CriterionOutcome {
266                condition: "$statusCode == 200".to_owned(),
267                passed,
268            }],
269            passed,
270            outputs: BTreeMap::new(),
271            action: None,
272            elapsed: Duration::from_millis(12),
273        }
274    }
275
276    #[test]
277    fn a_report_reads_as_what_happened() {
278        let report = ExecutionReport {
279            workflow_id: "buyPet".to_owned(),
280            outcome: Outcome::Succeeded,
281            outputs: BTreeMap::from([("pet".to_owned(), json!({ "id": 7 }))]),
282            steps: vec![record("findPet", 200, true)],
283        };
284        assert_eq!(
285            report.to_string(),
286            "workflow `buyPet` succeeded\n\
287             - findPet GET https://api.example.com/findPet → 200\n  \
288             pet = {\"id\":7}\n"
289        );
290        assert!(report.is_success());
291    }
292
293    #[test]
294    fn a_failure_and_a_retry_show_in_the_line() {
295        let mut failed = record("findPet", 503, false);
296        failed.attempt = 2;
297        failed.action = Some("retry".to_owned());
298        let report = ExecutionReport {
299            workflow_id: "buyPet".to_owned(),
300            outcome: Outcome::Failed,
301            outputs: BTreeMap::new(),
302            steps: vec![failed],
303        };
304        let text = report.to_string();
305        assert!(text.contains("workflow `buyPet` failed"), "{text}");
306        assert!(text.contains("(attempt 2)"), "{text}");
307        assert!(text.contains("— failed"), "{text}");
308        assert!(text.contains("— retry"), "{text}");
309        assert!(!report.is_success());
310    }
311
312    #[test]
313    fn a_step_that_called_a_workflow_reads_as_what_it_called() {
314        let record = StepRecord {
315            workflow_id: "buyPet".to_owned(),
316            step_id: "authenticate".to_owned(),
317            attempt: 1,
318            performed: Performed::Workflow {
319                workflow_id: "login".to_owned(),
320                outcome: Outcome::Succeeded,
321            },
322            criteria: Vec::new(),
323            passed: true,
324            outputs: BTreeMap::from([("token".to_owned(), json!("t-1"))]),
325            action: None,
326            elapsed: Duration::from_millis(30),
327        };
328        // It sent no request, so it has no method, URL or status to
329        // report — and says so rather than inventing them.
330        assert_eq!(record.status(), None);
331        assert_eq!(record.method(), None);
332        assert_eq!(record.url(), None);
333
334        let report = ExecutionReport {
335            workflow_id: "buyPet".to_owned(),
336            outcome: Outcome::Succeeded,
337            outputs: BTreeMap::new(),
338            steps: vec![record],
339        };
340        assert_eq!(
341            report.to_string(),
342            "workflow `buyPet` succeeded\n\
343             - authenticate → workflow `login` succeeded\n"
344        );
345    }
346
347    #[test]
348    fn a_step_that_sent_a_request_says_what_it_sent() {
349        let record = record("findPet", 200, true);
350        assert_eq!(record.status(), Some(200));
351        assert_eq!(record.method(), Some("GET"));
352        assert_eq!(record.url(), Some("https://api.example.com/findPet"));
353    }
354
355    #[test]
356    fn a_run_waiting_for_a_response_says_which_one() {
357        assert_eq!(
358            ExecutionError::Awaiting {
359                method: "GET".to_owned(),
360                url: "https://api.example.com/pets".to_owned(),
361            }
362            .to_string(),
363            "the run is waiting for a response to `GET https://api.example.com/pets` — supply it before advancing"
364        );
365    }
366
367    #[test]
368    fn an_outcome_reads_as_a_word() {
369        assert_eq!(Outcome::Succeeded.to_string(), "succeeded");
370        assert_eq!(Outcome::Failed.to_string(), "failed");
371        assert_eq!(Outcome::Ended.to_string(), "ended early");
372    }
373
374    #[test]
375    fn an_error_says_which_thing_was_missing() {
376        assert_eq!(
377            ExecutionError::UnknownWorkflow("nope".to_owned()).to_string(),
378            "the description has no workflow `nope`"
379        );
380        assert_eq!(
381            ExecutionError::UnknownStep {
382                workflow: "buyPet".to_owned(),
383                step: "nope".to_owned(),
384            }
385            .to_string(),
386            "workflow `buyPet` has no step `nope` to go to"
387        );
388        assert_eq!(
389            ExecutionError::Limit {
390                limit: "step",
391                at: 1000
392            }
393            .to_string(),
394            "the run stopped after reaching its step limit of 1000"
395        );
396    }
397}