use crate::criterion::CriterionError;
use crate::expression::ExpressionError;
use crate::http::ClientError;
use crate::operation::OperationError;
use crate::select::SelectError;
use serde_json::Value;
use std::collections::BTreeMap;
use std::fmt;
use std::time::Duration;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum Outcome {
#[default]
Succeeded,
Failed,
Ended,
}
impl fmt::Display for Outcome {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Outcome::Succeeded => "succeeded",
Outcome::Failed => "failed",
Outcome::Ended => "ended early",
})
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct CriterionOutcome {
pub condition: String,
pub passed: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum Performed {
Request {
method: String,
url: String,
status: u16,
},
Workflow {
workflow_id: String,
outcome: Outcome,
},
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct StepRecord {
pub workflow_id: String,
pub step_id: String,
pub attempt: u32,
pub performed: Performed,
pub criteria: Vec<CriterionOutcome>,
pub passed: bool,
pub outputs: BTreeMap<String, Value>,
pub action: Option<String>,
pub elapsed: Duration,
}
impl StepRecord {
#[must_use]
pub fn status(&self) -> Option<u16> {
match &self.performed {
Performed::Request { status, .. } => Some(*status),
Performed::Workflow { .. } => None,
}
}
#[must_use]
pub fn method(&self) -> Option<&str> {
match &self.performed {
Performed::Request { method, .. } => Some(method),
Performed::Workflow { .. } => None,
}
}
#[must_use]
pub fn url(&self) -> Option<&str> {
match &self.performed {
Performed::Request { url, .. } => Some(url),
Performed::Workflow { .. } => None,
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct ExecutionReport {
pub workflow_id: String,
pub outcome: Outcome,
pub outputs: BTreeMap<String, Value>,
pub steps: Vec<StepRecord>,
}
impl ExecutionReport {
#[must_use]
pub fn is_success(&self) -> bool {
self.outcome != Outcome::Failed
}
}
impl fmt::Display for ExecutionReport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "workflow `{}` {}", self.workflow_id, self.outcome)?;
for step in &self.steps {
match &step.performed {
Performed::Request {
method,
url,
status,
} => write!(f, "- {} {method} {url} → {status}", step.step_id)?,
Performed::Workflow {
workflow_id,
outcome,
} => write!(f, "- {} → workflow `{workflow_id}` {outcome}", step.step_id)?,
}
if step.attempt > 1 {
write!(f, " (attempt {})", step.attempt)?;
}
if !step.passed {
write!(f, " — failed")?;
}
if let Some(action) = &step.action {
write!(f, " — {action}")?;
}
writeln!(f)?;
}
for (name, value) in &self.outputs {
writeln!(f, " {name} = {value}")?;
}
Ok(())
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ExecutionError {
#[error("the description has no workflow `{0}`")]
UnknownWorkflow(String),
#[error("workflow `{workflow}` has no step `{step}` to go to")]
UnknownStep {
workflow: String,
step: String,
},
#[error("`dependsOn` is circular: {0}")]
Circular(String),
#[error(transparent)]
Operation(#[from] OperationError),
#[error(transparent)]
Expression(#[from] ExpressionError),
#[error(transparent)]
Select(#[from] SelectError),
#[error(transparent)]
Criterion(#[from] CriterionError),
#[error("the request could not be sent: {0}")]
Client(#[from] ClientError),
#[error("step `{step}` cannot be turned into a request: {reason}")]
BadRequest {
step: String,
reason: String,
},
#[error("the run stopped after reaching its {limit} limit of {at}")]
Limit {
limit: &'static str,
at: usize,
},
#[error("{0}")]
Unsupported(String),
#[error("a response arrived when no request was outstanding")]
NotWaiting,
#[error("the run is waiting for a response to `{method} {url}` — supply it before advancing")]
Awaiting {
method: String,
url: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn record(step_id: &str, status: u16, passed: bool) -> StepRecord {
StepRecord {
workflow_id: "buyPet".to_owned(),
step_id: step_id.to_owned(),
attempt: 1,
performed: Performed::Request {
method: "GET".to_owned(),
url: format!("https://api.example.com/{step_id}"),
status,
},
criteria: vec![CriterionOutcome {
condition: "$statusCode == 200".to_owned(),
passed,
}],
passed,
outputs: BTreeMap::new(),
action: None,
elapsed: Duration::from_millis(12),
}
}
#[test]
fn a_report_reads_as_what_happened() {
let report = ExecutionReport {
workflow_id: "buyPet".to_owned(),
outcome: Outcome::Succeeded,
outputs: BTreeMap::from([("pet".to_owned(), json!({ "id": 7 }))]),
steps: vec![record("findPet", 200, true)],
};
assert_eq!(
report.to_string(),
"workflow `buyPet` succeeded\n\
- findPet GET https://api.example.com/findPet → 200\n \
pet = {\"id\":7}\n"
);
assert!(report.is_success());
}
#[test]
fn a_failure_and_a_retry_show_in_the_line() {
let mut failed = record("findPet", 503, false);
failed.attempt = 2;
failed.action = Some("retry".to_owned());
let report = ExecutionReport {
workflow_id: "buyPet".to_owned(),
outcome: Outcome::Failed,
outputs: BTreeMap::new(),
steps: vec![failed],
};
let text = report.to_string();
assert!(text.contains("workflow `buyPet` failed"), "{text}");
assert!(text.contains("(attempt 2)"), "{text}");
assert!(text.contains("— failed"), "{text}");
assert!(text.contains("— retry"), "{text}");
assert!(!report.is_success());
}
#[test]
fn a_step_that_called_a_workflow_reads_as_what_it_called() {
let record = StepRecord {
workflow_id: "buyPet".to_owned(),
step_id: "authenticate".to_owned(),
attempt: 1,
performed: Performed::Workflow {
workflow_id: "login".to_owned(),
outcome: Outcome::Succeeded,
},
criteria: Vec::new(),
passed: true,
outputs: BTreeMap::from([("token".to_owned(), json!("t-1"))]),
action: None,
elapsed: Duration::from_millis(30),
};
assert_eq!(record.status(), None);
assert_eq!(record.method(), None);
assert_eq!(record.url(), None);
let report = ExecutionReport {
workflow_id: "buyPet".to_owned(),
outcome: Outcome::Succeeded,
outputs: BTreeMap::new(),
steps: vec![record],
};
assert_eq!(
report.to_string(),
"workflow `buyPet` succeeded\n\
- authenticate → workflow `login` succeeded\n"
);
}
#[test]
fn a_step_that_sent_a_request_says_what_it_sent() {
let record = record("findPet", 200, true);
assert_eq!(record.status(), Some(200));
assert_eq!(record.method(), Some("GET"));
assert_eq!(record.url(), Some("https://api.example.com/findPet"));
}
#[test]
fn a_run_waiting_for_a_response_says_which_one() {
assert_eq!(
ExecutionError::Awaiting {
method: "GET".to_owned(),
url: "https://api.example.com/pets".to_owned(),
}
.to_string(),
"the run is waiting for a response to `GET https://api.example.com/pets` — supply it before advancing"
);
}
#[test]
fn an_outcome_reads_as_a_word() {
assert_eq!(Outcome::Succeeded.to_string(), "succeeded");
assert_eq!(Outcome::Failed.to_string(), "failed");
assert_eq!(Outcome::Ended.to_string(), "ended early");
}
#[test]
fn an_error_says_which_thing_was_missing() {
assert_eq!(
ExecutionError::UnknownWorkflow("nope".to_owned()).to_string(),
"the description has no workflow `nope`"
);
assert_eq!(
ExecutionError::UnknownStep {
workflow: "buyPet".to_owned(),
step: "nope".to_owned(),
}
.to_string(),
"workflow `buyPet` has no step `nope` to go to"
);
assert_eq!(
ExecutionError::Limit {
limit: "step",
at: 1000
}
.to_string(),
"the run stopped after reaching its step limit of 1000"
);
}
}