ironflow_api/entities/
create_run.rs1use 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#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
31#[derive(Debug, Deserialize)]
32pub struct CreateRunRequest {
33 pub workflow: String,
35 #[cfg_attr(feature = "openapi", schema(value_type = Option<std::collections::HashMap<String, serde_json::Value>>))]
37 pub payload: Option<Value>,
38 #[serde(default)]
40 pub labels: Option<HashMap<String, String>>,
41 #[serde(default)]
43 pub scheduled_at: Option<DateTime<Utc>>,
44 #[serde(default)]
53 pub max_retries: Option<u32>,
54 #[cfg_attr(feature = "openapi", schema(value_type = Option<f64>))]
59 #[serde(default)]
60 pub max_cost_usd: Option<Decimal>,
61 #[serde(default)]
68 pub concurrency_key: Option<String>,
69}
70
71impl CreateRunRequest {
72 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum IdempotencyKeyError {
129 Empty,
131 TooLong,
133 NotPrintableAscii,
135}
136
137impl IdempotencyKeyError {
138 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
160pub 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 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 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}