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_CONCURRENCY_KEY_LEN, 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///     concurrency_key: Some("issue:12".to_string()),
27/// };
28/// assert_eq!(req.workflow, "deploy");
29/// ```
30#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
31#[derive(Debug, Deserialize)]
32pub struct CreateRunRequest {
33    /// The workflow name to trigger.
34    pub workflow: String,
35    /// Optional input payload for the workflow.
36    #[cfg_attr(feature = "openapi", schema(value_type = Option<std::collections::HashMap<String, serde_json::Value>>))]
37    pub payload: Option<Value>,
38    /// Optional key-value labels for categorization and filtering.
39    #[serde(default)]
40    pub labels: Option<HashMap<String, String>>,
41    /// Optional deferred execution time. `None` means run immediately.
42    #[serde(default)]
43    pub scheduled_at: Option<DateTime<Utc>>,
44    /// How many times the run may be replayed automatically after a transient
45    /// failure. Defaults to `0`, meaning no automatic retry.
46    ///
47    /// Each retry waits an exponential backoff (30 s, 2 min, 8 min, capped at
48    /// 15 min) before the run is replayed from the start. Failures that cannot
49    /// succeed on replay -- an unknown workflow, an invalid payload, an
50    /// exhausted agent budget, a rejected approval, a manual cancellation --
51    /// consume no attempt.
52    #[serde(default)]
53    pub max_retries: Option<u32>,
54    /// Optional cumulative cost cap for this run, in USD.
55    ///
56    /// Overrides the workflow default and the server default. `None` falls back
57    /// to those. Must be zero or positive.
58    #[cfg_attr(feature = "openapi", schema(value_type = Option<f64>))]
59    #[serde(default)]
60    pub max_cost_usd: Option<Decimal>,
61    /// Optional exclusivity key, at most 255 bytes.
62    ///
63    /// While a non-terminal run (pending, running, sleeping, retrying,
64    /// awaiting approval) holds the same key, the request is refused with
65    /// `409 CONCURRENCY_CONFLICT` naming that run. The key is released when
66    /// the run completes, fails, ends with a warning or is cancelled.
67    #[serde(default)]
68    pub concurrency_key: Option<String>,
69}
70
71impl CreateRunRequest {
72    /// Validate the request body.
73    ///
74    /// # Errors
75    ///
76    /// Returns a human-readable message when `max_cost_usd` is negative, or
77    /// when `concurrency_key` is blank or longer than
78    /// [`MAX_CONCURRENCY_KEY_LEN`] bytes.
79    ///
80    /// # Examples
81    ///
82    /// ```
83    /// use ironflow_api::entities::CreateRunRequest;
84    /// use rust_decimal::Decimal;
85    ///
86    /// let req = CreateRunRequest {
87    ///     workflow: "deploy".to_string(),
88    ///     payload: None,
89    ///     labels: None,
90    ///     scheduled_at: None,
91    ///     max_retries: None,
92    ///     max_cost_usd: Some(Decimal::new(-1, 0)),
93    ///     concurrency_key: None,
94    /// };
95    /// assert!(req.validate().is_err());
96    /// ```
97    pub fn validate(&self) -> Result<(), String> {
98        if let Some(cap) = self.max_cost_usd
99            && cap < Decimal::ZERO
100        {
101            return Err("max_cost_usd must be zero or positive".to_string());
102        }
103        match self.concurrency_key.as_deref() {
104            Some(key) if key.trim().is_empty() => {
105                Err("concurrency_key must not be empty".to_string())
106            }
107            Some(key) if key.len() > MAX_CONCURRENCY_KEY_LEN => Err(format!(
108                "concurrency_key must be at most {MAX_CONCURRENCY_KEY_LEN} bytes"
109            )),
110            _ => Ok(()),
111        }
112    }
113}
114
115/// Why an `Idempotency-Key` header value was rejected.
116///
117/// # Examples
118///
119/// ```
120/// use ironflow_api::entities::{IdempotencyKeyError, validate_idempotency_key};
121///
122/// assert_eq!(
123///     validate_idempotency_key(""),
124///     Err(IdempotencyKeyError::Empty),
125/// );
126/// ```
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum IdempotencyKeyError {
129    /// The header was present but carried no value.
130    Empty,
131    /// The value exceeds [`MAX_IDEMPOTENCY_KEY_LEN`] bytes.
132    TooLong,
133    /// The value contains a byte outside printable ASCII.
134    NotPrintableAscii,
135}
136
137impl IdempotencyKeyError {
138    /// Client-facing explanation of the rejection.
139    ///
140    /// # Examples
141    ///
142    /// ```
143    /// use ironflow_api::entities::IdempotencyKeyError;
144    ///
145    /// assert!(IdempotencyKeyError::Empty.message().contains("empty"));
146    /// ```
147    pub fn message(&self) -> String {
148        match self {
149            IdempotencyKeyError::Empty => "Idempotency-Key must not be empty".to_string(),
150            IdempotencyKeyError::TooLong => {
151                format!("Idempotency-Key must be at most {MAX_IDEMPOTENCY_KEY_LEN} bytes")
152            }
153            IdempotencyKeyError::NotPrintableAscii => {
154                "Idempotency-Key must contain only printable ASCII characters".to_string()
155            }
156        }
157    }
158}
159
160/// Validate an `Idempotency-Key` header value.
161///
162/// A key must be non-empty, at most [`MAX_IDEMPOTENCY_KEY_LEN`] bytes, and made
163/// only of printable ASCII. Empty keys are rejected because they would otherwise
164/// become a single key shared by every client.
165///
166/// # Errors
167///
168/// Returns [`IdempotencyKeyError`] describing which rule the value broke.
169///
170/// # Examples
171///
172/// ```
173/// use ironflow_api::entities::{IdempotencyKeyError, validate_idempotency_key};
174///
175/// assert!(validate_idempotency_key("github:abc-123").is_ok());
176/// assert_eq!(
177///     validate_idempotency_key("clé"),
178///     Err(IdempotencyKeyError::NotPrintableAscii),
179/// );
180/// ```
181pub fn validate_idempotency_key(key: &str) -> Result<(), IdempotencyKeyError> {
182    if key.is_empty() {
183        return Err(IdempotencyKeyError::Empty);
184    }
185    if key.len() > MAX_IDEMPOTENCY_KEY_LEN {
186        return Err(IdempotencyKeyError::TooLong);
187    }
188    if !key.bytes().all(|b| b.is_ascii_graphic()) {
189        return Err(IdempotencyKeyError::NotPrintableAscii);
190    }
191    Ok(())
192}
193
194#[cfg(test)]
195mod tests {
196    use serde_json::from_str;
197
198    use super::*;
199
200    fn request(max_cost_usd: Option<Decimal>) -> CreateRunRequest {
201        CreateRunRequest {
202            workflow: "deploy".to_string(),
203            payload: None,
204            labels: None,
205            scheduled_at: None,
206            max_retries: None,
207            max_cost_usd,
208            concurrency_key: None,
209        }
210    }
211
212    fn keyed(concurrency_key: &str) -> CreateRunRequest {
213        CreateRunRequest {
214            concurrency_key: Some(concurrency_key.to_string()),
215            ..request(None)
216        }
217    }
218
219    #[test]
220    fn validate_accepts_absent_zero_and_positive_caps() {
221        assert!(request(None).validate().is_ok());
222        assert!(request(Some(Decimal::ZERO)).validate().is_ok());
223        assert!(request(Some(Decimal::new(150, 2))).validate().is_ok());
224    }
225
226    #[test]
227    fn validate_rejects_negative_cap() {
228        let err = request(Some(Decimal::new(-1, 2)))
229            .validate()
230            .expect_err("negative cap must be rejected");
231        assert!(err.contains("max_cost_usd"));
232    }
233
234    #[test]
235    fn max_cost_usd_defaults_to_none_when_absent() {
236        let req: CreateRunRequest =
237            serde_json::from_str(r#"{"workflow":"deploy"}"#).expect("deserialize");
238        assert!(req.max_cost_usd.is_none());
239    }
240
241    #[test]
242    fn max_cost_usd_parses_from_json_number() {
243        let req: CreateRunRequest =
244            serde_json::from_str(r#"{"workflow":"deploy","max_cost_usd":2.5}"#)
245                .expect("deserialize");
246        assert_eq!(req.max_cost_usd, Some(Decimal::new(25, 1)));
247    }
248
249    #[test]
250    fn concurrency_key_defaults_to_none_when_absent() {
251        let req: CreateRunRequest = from_str(r#"{"workflow":"deploy"}"#).expect("deserialize");
252        assert!(req.concurrency_key.is_none());
253        assert!(req.validate().is_ok());
254    }
255
256    #[test]
257    fn concurrency_key_parses_from_json() {
258        let req: CreateRunRequest =
259            from_str(r#"{"workflow":"deploy","concurrency_key":"issue:12"}"#).expect("deserialize");
260        assert_eq!(req.concurrency_key.as_deref(), Some("issue:12"));
261    }
262
263    #[test]
264    fn validate_accepts_a_concurrency_key_up_to_the_limit() {
265        assert!(keyed("issue:12").validate().is_ok());
266        assert!(keyed("cl\u{e9}:\u{e9}lodie").validate().is_ok());
267        let longest = "k".repeat(MAX_CONCURRENCY_KEY_LEN);
268        assert!(keyed(&longest).validate().is_ok());
269    }
270
271    #[test]
272    fn validate_rejects_an_empty_or_blank_concurrency_key() {
273        for key in ["", "   ", "\t\n"] {
274            let err = keyed(key)
275                .validate()
276                .expect_err("blank key must be rejected");
277            assert_eq!(err, "concurrency_key must not be empty");
278        }
279    }
280
281    #[test]
282    fn validate_rejects_a_concurrency_key_over_the_limit() {
283        let err = keyed(&"k".repeat(MAX_CONCURRENCY_KEY_LEN + 1))
284            .validate()
285            .expect_err("over-long key must be rejected");
286        assert_eq!(err, "concurrency_key must be at most 255 bytes");
287    }
288
289    #[test]
290    fn validate_counts_the_concurrency_key_limit_in_bytes() {
291        // 128 two-byte characters: 128 chars but 256 bytes.
292        let err = keyed(&"\u{e9}".repeat(128))
293            .validate()
294            .expect_err("the limit is in bytes, not characters");
295        assert!(err.contains("255 bytes"));
296    }
297
298    #[test]
299    fn max_retries_defaults_to_none_when_absent() {
300        let req: CreateRunRequest =
301            serde_json::from_str(r#"{"workflow":"deploy"}"#).expect("deserialize");
302        assert!(req.max_retries.is_none());
303    }
304
305    #[test]
306    fn max_retries_parses_from_json_number() {
307        let req: CreateRunRequest =
308            serde_json::from_str(r#"{"workflow":"deploy","max_retries":3}"#).expect("deserialize");
309        assert_eq!(req.max_retries, Some(3));
310    }
311
312    #[test]
313    fn accepts_a_provider_delivery_id() {
314        assert!(validate_idempotency_key("github:8f4e2a10-1234-4bcd-9876-abcdef012345").is_ok());
315    }
316
317    #[test]
318    fn rejects_an_empty_key() {
319        assert_eq!(
320            validate_idempotency_key(""),
321            Err(IdempotencyKeyError::Empty)
322        );
323    }
324
325    #[test]
326    fn accepts_a_key_at_the_length_limit() {
327        let key = "a".repeat(MAX_IDEMPOTENCY_KEY_LEN);
328        assert!(validate_idempotency_key(&key).is_ok());
329    }
330
331    #[test]
332    fn rejects_a_key_one_byte_over_the_limit() {
333        let key = "a".repeat(MAX_IDEMPOTENCY_KEY_LEN + 1);
334        assert_eq!(
335            validate_idempotency_key(&key),
336            Err(IdempotencyKeyError::TooLong)
337        );
338    }
339
340    #[test]
341    fn rejects_non_ascii() {
342        assert_eq!(
343            validate_idempotency_key("clé-🚀"),
344            Err(IdempotencyKeyError::NotPrintableAscii)
345        );
346    }
347
348    #[test]
349    fn rejects_control_characters() {
350        assert_eq!(
351            validate_idempotency_key("abc\ndef"),
352            Err(IdempotencyKeyError::NotPrintableAscii)
353        );
354    }
355
356    #[test]
357    fn rejects_a_space() {
358        // `is_ascii_graphic` excludes the space: a bare space is not a usable key.
359        assert_eq!(
360            validate_idempotency_key("abc def"),
361            Err(IdempotencyKeyError::NotPrintableAscii)
362        );
363    }
364
365    #[test]
366    fn error_messages_name_the_broken_rule() {
367        assert!(IdempotencyKeyError::Empty.message().contains("empty"));
368        assert!(
369            IdempotencyKeyError::TooLong
370                .message()
371                .contains(&MAX_IDEMPOTENCY_KEY_LEN.to_string())
372        );
373        assert!(
374            IdempotencyKeyError::NotPrintableAscii
375                .message()
376                .contains("ASCII")
377        );
378    }
379}