use crate::common::reusable::ReusableOr;
use crate::v1_0::criterion::Criterion;
use crate::v1_0::failure_action::FailureAction;
use crate::v1_0::parameter::Parameter;
use crate::v1_0::request_body::RequestBody;
use crate::v1_0::success_action::SuccessAction;
use crate::validation::{Context, ValidateWithContext};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Default)]
pub struct Step {
#[serde(rename = "stepId")]
pub step_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(rename = "operationId", skip_serializing_if = "Option::is_none")]
pub operation_id: Option<String>,
#[serde(rename = "operationPath", skip_serializing_if = "Option::is_none")]
pub operation_path: Option<String>,
#[serde(rename = "workflowId", skip_serializing_if = "Option::is_none")]
pub workflow_id: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub parameters: Vec<ReusableOr<Parameter>>,
#[serde(rename = "requestBody", skip_serializing_if = "Option::is_none")]
pub request_body: Option<RequestBody>,
#[serde(
rename = "successCriteria",
default,
skip_serializing_if = "Vec::is_empty"
)]
pub success_criteria: Vec<Criterion>,
#[serde(rename = "onSuccess", default, skip_serializing_if = "Vec::is_empty")]
pub on_success: Vec<ReusableOr<SuccessAction>>,
#[serde(rename = "onFailure", default, skip_serializing_if = "Vec::is_empty")]
pub on_failure: Vec<ReusableOr<FailureAction>>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub outputs: BTreeMap<String, String>,
#[serde(flatten)]
#[serde(with = "crate::common::extensions")]
#[serde(skip_serializing_if = "Option::is_none")]
pub extensions: Option<BTreeMap<String, serde_json::Value>>,
}
impl Step {
fn is_operation(&self) -> bool {
self.operation_id.is_some() || self.operation_path.is_some()
}
}
impl ValidateWithContext for Step {
fn validate_with_context(&self, ctx: &mut Context) {
ctx.require_non_empty("stepId", &self.step_id);
let targets = [
self.operation_id.is_some(),
self.operation_path.is_some(),
self.workflow_id.is_some(),
];
match targets.iter().filter(|set| **set).count() {
1 => {}
0 => {
ctx.error("must set exactly one of `operationId`, `operationPath`, or `workflowId`")
}
_ => {
ctx.error("`operationId`, `operationPath`, and `workflowId` are mutually exclusive")
}
}
let is_operation = self.is_operation();
for (i, parameter) in self.parameters.iter().enumerate() {
ctx.in_index("parameters", i, |ctx| {
parameter.validate_with_context(ctx);
if is_operation
&& let ReusableOr::Item(p) = parameter
&& p.in_.is_none()
{
ctx.error_field("in", "is required for operation steps");
}
});
}
if let Some(request_body) = &self.request_body {
ctx.in_field("requestBody", |ctx| request_body.validate_with_context(ctx));
}
for (i, criterion) in self.success_criteria.iter().enumerate() {
ctx.in_index("successCriteria", i, |ctx| {
criterion.validate_with_context(ctx)
});
}
for (i, action) in self.on_success.iter().enumerate() {
ctx.in_index("onSuccess", i, |ctx| action.validate_with_context(ctx));
}
for (i, action) in self.on_failure.iter().enumerate() {
ctx.in_index("onFailure", i, |ctx| action.validate_with_context(ctx));
}
ctx.validate_map_keys("outputs", &self.outputs);
}
}
#[cfg(test)]
mod tests {
use super::*;
use enumset::EnumSet;
use serde_json::json;
fn validate(step: &Step) -> Vec<String> {
let mut ctx = Context::with_path(EnumSet::empty(), "#.steps[0]");
step.validate_with_context(&mut ctx);
ctx.errors.iter().map(ToString::to_string).collect()
}
#[test]
fn operation_step_round_trips() {
let step: Step = serde_json::from_value(json!({
"stepId": "findPet",
"operationId": "getPetById",
"parameters": [ { "name": "petId", "in": "path", "value": "$inputs.petId" } ],
"successCriteria": [ { "condition": "$statusCode == 200" } ],
"outputs": { "pet": "$response.body" },
}))
.unwrap();
assert_eq!(step.step_id, "findPet");
assert_eq!(step.parameters.len(), 1);
assert!(validate(&step).is_empty());
}
#[test]
fn missing_operation_target_is_rejected() {
let step = Step {
step_id: "s".into(),
..Default::default()
};
assert!(validate(&step).iter().any(|e| e.contains("exactly one of")));
}
#[test]
fn multiple_operation_targets_are_rejected() {
let step = Step {
step_id: "s".into(),
operation_id: Some("op".into()),
workflow_id: Some("wf".into()),
..Default::default()
};
assert!(
validate(&step)
.iter()
.any(|e| e.contains("mutually exclusive"))
);
}
#[test]
fn operation_parameter_requires_in() {
let step = Step {
step_id: "s".into(),
operation_id: Some("op".into()),
parameters: vec![ReusableOr::Item(Parameter {
name: "p".into(),
value: json!("v"),
..Default::default()
})],
..Default::default()
};
assert!(
validate(&step)
.iter()
.any(|e| e == "#.steps[0].parameters[0].in: is required for operation steps")
);
}
#[test]
fn workflow_step_parameter_does_not_require_in() {
let step = Step {
step_id: "s".into(),
workflow_id: Some("wf".into()),
parameters: vec![ReusableOr::Item(Parameter {
name: "p".into(),
value: json!("v"),
..Default::default()
})],
..Default::default()
};
assert!(validate(&step).is_empty());
}
#[test]
fn reusable_parameter_skips_in_check() {
let step: Step = serde_json::from_value(json!({
"stepId": "s",
"operationId": "op",
"parameters": [ { "reference": "$components.parameters.petId" } ],
}))
.unwrap();
assert!(validate(&step).is_empty());
}
#[test]
fn bad_output_key_is_rejected() {
let step: Step = serde_json::from_value(json!({
"stepId": "s",
"workflowId": "wf",
"outputs": { "bad key": "$x" },
}))
.unwrap();
assert!(validate(&step).iter().any(|e| e.contains("key must match")));
}
}