Skip to main content

ironflow_api/entities/
create_run.rs

1//! Request type for triggering a workflow.
2
3use std::collections::HashMap;
4
5use chrono::{DateTime, Utc};
6use ironflow_store::models::MAX_IDEMPOTENCY_KEY_LEN;
7use rust_decimal::Decimal;
8use serde::Deserialize;
9use serde_json::Value;
10
11/// Request to trigger a workflow.
12///
13/// # Examples
14///
15/// ```
16/// use ironflow_api::entities::CreateRunRequest;
17/// use serde_json::json;
18///
19/// let req = CreateRunRequest {
20///     workflow: "deploy".to_string(),
21///     payload: Some(json!({"env": "prod"})),
22///     labels: None,
23///     scheduled_at: None,
24///     max_retries: Some(2),
25///     max_cost_usd: None,
26/// };
27/// assert_eq!(req.workflow, "deploy");
28/// ```
29#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
30#[derive(Debug, Deserialize)]
31pub struct CreateRunRequest {
32    /// The workflow name to trigger.
33    pub workflow: String,
34    /// Optional input payload for the workflow.
35    #[cfg_attr(feature = "openapi", schema(value_type = Option<std::collections::HashMap<String, serde_json::Value>>))]
36    pub payload: Option<Value>,
37    /// Optional key-value labels for categorization and filtering.
38    #[serde(default)]
39    pub labels: Option<HashMap<String, String>>,
40    /// Optional deferred execution time. `None` means run immediately.
41    #[serde(default)]
42    pub scheduled_at: Option<DateTime<Utc>>,
43    /// How many times the run may be replayed automatically after a transient
44    /// failure. Defaults to `0`, meaning no automatic retry.
45    ///
46    /// Each retry waits an exponential backoff (30 s, 2 min, 8 min, capped at
47    /// 15 min) before the run is replayed from the start. Failures that cannot
48    /// succeed on replay -- an unknown workflow, an invalid payload, an
49    /// exhausted agent budget, a rejected approval, a manual cancellation --
50    /// consume no attempt.
51    #[serde(default)]
52    pub max_retries: Option<u32>,
53    /// Optional cumulative cost cap for this run, in USD.
54    ///
55    /// Overrides the workflow default and the server default. `None` falls back
56    /// to those. Must be zero or positive.
57    #[cfg_attr(feature = "openapi", schema(value_type = Option<f64>))]
58    #[serde(default)]
59    pub max_cost_usd: Option<Decimal>,
60}
61
62impl CreateRunRequest {
63    /// Validate the request body.
64    ///
65    /// # Errors
66    ///
67    /// Returns a human-readable message when `max_cost_usd` is negative.
68    ///
69    /// # Examples
70    ///
71    /// ```
72    /// use ironflow_api::entities::CreateRunRequest;
73    /// use rust_decimal::Decimal;
74    ///
75    /// let req = CreateRunRequest {
76    ///     workflow: "deploy".to_string(),
77    ///     payload: None,
78    ///     labels: None,
79    ///     scheduled_at: None,
80    ///     max_retries: None,
81    ///     max_cost_usd: Some(Decimal::new(-1, 0)),
82    /// };
83    /// assert!(req.validate().is_err());
84    /// ```
85    pub fn validate(&self) -> Result<(), String> {
86        match self.max_cost_usd {
87            Some(cap) if cap < Decimal::ZERO => {
88                Err("max_cost_usd must be zero or positive".to_string())
89            }
90            _ => Ok(()),
91        }
92    }
93}
94
95/// Why an `Idempotency-Key` header value was rejected.
96///
97/// # Examples
98///
99/// ```
100/// use ironflow_api::entities::{IdempotencyKeyError, validate_idempotency_key};
101///
102/// assert_eq!(
103///     validate_idempotency_key(""),
104///     Err(IdempotencyKeyError::Empty),
105/// );
106/// ```
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum IdempotencyKeyError {
109    /// The header was present but carried no value.
110    Empty,
111    /// The value exceeds [`MAX_IDEMPOTENCY_KEY_LEN`] bytes.
112    TooLong,
113    /// The value contains a byte outside printable ASCII.
114    NotPrintableAscii,
115}
116
117impl IdempotencyKeyError {
118    /// Client-facing explanation of the rejection.
119    ///
120    /// # Examples
121    ///
122    /// ```
123    /// use ironflow_api::entities::IdempotencyKeyError;
124    ///
125    /// assert!(IdempotencyKeyError::Empty.message().contains("empty"));
126    /// ```
127    pub fn message(&self) -> String {
128        match self {
129            IdempotencyKeyError::Empty => "Idempotency-Key must not be empty".to_string(),
130            IdempotencyKeyError::TooLong => {
131                format!("Idempotency-Key must be at most {MAX_IDEMPOTENCY_KEY_LEN} bytes")
132            }
133            IdempotencyKeyError::NotPrintableAscii => {
134                "Idempotency-Key must contain only printable ASCII characters".to_string()
135            }
136        }
137    }
138}
139
140/// Validate an `Idempotency-Key` header value.
141///
142/// A key must be non-empty, at most [`MAX_IDEMPOTENCY_KEY_LEN`] bytes, and made
143/// only of printable ASCII. Empty keys are rejected because they would otherwise
144/// become a single key shared by every client.
145///
146/// # Errors
147///
148/// Returns [`IdempotencyKeyError`] describing which rule the value broke.
149///
150/// # Examples
151///
152/// ```
153/// use ironflow_api::entities::{IdempotencyKeyError, validate_idempotency_key};
154///
155/// assert!(validate_idempotency_key("github:abc-123").is_ok());
156/// assert_eq!(
157///     validate_idempotency_key("clé"),
158///     Err(IdempotencyKeyError::NotPrintableAscii),
159/// );
160/// ```
161pub fn validate_idempotency_key(key: &str) -> Result<(), IdempotencyKeyError> {
162    if key.is_empty() {
163        return Err(IdempotencyKeyError::Empty);
164    }
165    if key.len() > MAX_IDEMPOTENCY_KEY_LEN {
166        return Err(IdempotencyKeyError::TooLong);
167    }
168    if !key.bytes().all(|b| b.is_ascii_graphic()) {
169        return Err(IdempotencyKeyError::NotPrintableAscii);
170    }
171    Ok(())
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    fn request(max_cost_usd: Option<Decimal>) -> CreateRunRequest {
179        CreateRunRequest {
180            workflow: "deploy".to_string(),
181            payload: None,
182            labels: None,
183            scheduled_at: None,
184            max_retries: None,
185            max_cost_usd,
186        }
187    }
188
189    #[test]
190    fn validate_accepts_absent_zero_and_positive_caps() {
191        assert!(request(None).validate().is_ok());
192        assert!(request(Some(Decimal::ZERO)).validate().is_ok());
193        assert!(request(Some(Decimal::new(150, 2))).validate().is_ok());
194    }
195
196    #[test]
197    fn validate_rejects_negative_cap() {
198        let err = request(Some(Decimal::new(-1, 2)))
199            .validate()
200            .expect_err("negative cap must be rejected");
201        assert!(err.contains("max_cost_usd"));
202    }
203
204    #[test]
205    fn max_cost_usd_defaults_to_none_when_absent() {
206        let req: CreateRunRequest =
207            serde_json::from_str(r#"{"workflow":"deploy"}"#).expect("deserialize");
208        assert!(req.max_cost_usd.is_none());
209    }
210
211    #[test]
212    fn max_cost_usd_parses_from_json_number() {
213        let req: CreateRunRequest =
214            serde_json::from_str(r#"{"workflow":"deploy","max_cost_usd":2.5}"#)
215                .expect("deserialize");
216        assert_eq!(req.max_cost_usd, Some(Decimal::new(25, 1)));
217    }
218
219    #[test]
220    fn max_retries_defaults_to_none_when_absent() {
221        let req: CreateRunRequest =
222            serde_json::from_str(r#"{"workflow":"deploy"}"#).expect("deserialize");
223        assert!(req.max_retries.is_none());
224    }
225
226    #[test]
227    fn max_retries_parses_from_json_number() {
228        let req: CreateRunRequest =
229            serde_json::from_str(r#"{"workflow":"deploy","max_retries":3}"#).expect("deserialize");
230        assert_eq!(req.max_retries, Some(3));
231    }
232
233    #[test]
234    fn accepts_a_provider_delivery_id() {
235        assert!(validate_idempotency_key("github:8f4e2a10-1234-4bcd-9876-abcdef012345").is_ok());
236    }
237
238    #[test]
239    fn rejects_an_empty_key() {
240        assert_eq!(
241            validate_idempotency_key(""),
242            Err(IdempotencyKeyError::Empty)
243        );
244    }
245
246    #[test]
247    fn accepts_a_key_at_the_length_limit() {
248        let key = "a".repeat(MAX_IDEMPOTENCY_KEY_LEN);
249        assert!(validate_idempotency_key(&key).is_ok());
250    }
251
252    #[test]
253    fn rejects_a_key_one_byte_over_the_limit() {
254        let key = "a".repeat(MAX_IDEMPOTENCY_KEY_LEN + 1);
255        assert_eq!(
256            validate_idempotency_key(&key),
257            Err(IdempotencyKeyError::TooLong)
258        );
259    }
260
261    #[test]
262    fn rejects_non_ascii() {
263        assert_eq!(
264            validate_idempotency_key("clé-🚀"),
265            Err(IdempotencyKeyError::NotPrintableAscii)
266        );
267    }
268
269    #[test]
270    fn rejects_control_characters() {
271        assert_eq!(
272            validate_idempotency_key("abc\ndef"),
273            Err(IdempotencyKeyError::NotPrintableAscii)
274        );
275    }
276
277    #[test]
278    fn rejects_a_space() {
279        // `is_ascii_graphic` excludes the space: a bare space is not a usable key.
280        assert_eq!(
281            validate_idempotency_key("abc def"),
282            Err(IdempotencyKeyError::NotPrintableAscii)
283        );
284    }
285
286    #[test]
287    fn error_messages_name_the_broken_rule() {
288        assert!(IdempotencyKeyError::Empty.message().contains("empty"));
289        assert!(
290            IdempotencyKeyError::TooLong
291                .message()
292                .contains(&MAX_IDEMPOTENCY_KEY_LEN.to_string())
293        );
294        assert!(
295            IdempotencyKeyError::NotPrintableAscii
296                .message()
297                .contains("ASCII")
298        );
299    }
300}