1use std::collections::HashMap;
4
5use chrono::{DateTime, Utc};
6use ironflow_store::models::{
7 ConcurrencyLimit, MAX_CONCURRENCY_KEY_LEN, MAX_IDEMPOTENCY_KEY_LEN, validate_concurrency_limits,
8};
9use rust_decimal::Decimal;
10use serde::Deserialize;
11use serde_json::Value;
12
13#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
34#[derive(Debug, Deserialize)]
35pub struct CreateRunRequest {
36 pub workflow: String,
38 #[cfg_attr(feature = "openapi", schema(value_type = Option<std::collections::HashMap<String, serde_json::Value>>))]
40 pub payload: Option<Value>,
41 #[serde(default)]
43 pub labels: Option<HashMap<String, String>>,
44 #[serde(default)]
46 pub scheduled_at: Option<DateTime<Utc>>,
47 #[serde(default)]
56 pub max_retries: Option<u32>,
57 #[cfg_attr(feature = "openapi", schema(value_type = Option<f64>))]
62 #[serde(default)]
63 pub max_cost_usd: Option<Decimal>,
64 #[serde(default)]
71 pub concurrency_key: Option<String>,
72 #[serde(default)]
80 pub concurrency_limits: Vec<ConcurrencyLimit>,
81}
82
83impl CreateRunRequest {
84 pub fn validate(&self) -> Result<(), String> {
112 if let Some(cap) = self.max_cost_usd
113 && cap < Decimal::ZERO
114 {
115 return Err("max_cost_usd must be zero or positive".to_string());
116 }
117 match self.concurrency_key.as_deref() {
118 Some(key) if key.trim().is_empty() => {
119 return Err("concurrency_key must not be empty".to_string());
120 }
121 Some(key) if key.len() > MAX_CONCURRENCY_KEY_LEN => {
122 return Err(format!(
123 "concurrency_key must be at most {MAX_CONCURRENCY_KEY_LEN} bytes"
124 ));
125 }
126 _ => {}
127 }
128 validate_concurrency_limits(&self.concurrency_limits)
129 .map_err(|e| format!("concurrency_limits: {e}"))
130 }
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum IdempotencyKeyError {
147 Empty,
149 TooLong,
151 NotPrintableAscii,
153}
154
155impl IdempotencyKeyError {
156 pub fn message(&self) -> String {
166 match self {
167 IdempotencyKeyError::Empty => "Idempotency-Key must not be empty".to_string(),
168 IdempotencyKeyError::TooLong => {
169 format!("Idempotency-Key must be at most {MAX_IDEMPOTENCY_KEY_LEN} bytes")
170 }
171 IdempotencyKeyError::NotPrintableAscii => {
172 "Idempotency-Key must contain only printable ASCII characters".to_string()
173 }
174 }
175 }
176}
177
178pub fn validate_idempotency_key(key: &str) -> Result<(), IdempotencyKeyError> {
200 if key.is_empty() {
201 return Err(IdempotencyKeyError::Empty);
202 }
203 if key.len() > MAX_IDEMPOTENCY_KEY_LEN {
204 return Err(IdempotencyKeyError::TooLong);
205 }
206 if !key.bytes().all(|b| b.is_ascii_graphic()) {
207 return Err(IdempotencyKeyError::NotPrintableAscii);
208 }
209 Ok(())
210}
211
212#[cfg(test)]
213mod tests {
214 use serde_json::from_str;
215
216 use super::*;
217
218 fn request(max_cost_usd: Option<Decimal>) -> CreateRunRequest {
219 CreateRunRequest {
220 workflow: "deploy".to_string(),
221 payload: None,
222 labels: None,
223 scheduled_at: None,
224 max_retries: None,
225 max_cost_usd,
226 concurrency_key: None,
227 concurrency_limits: Vec::new(),
228 }
229 }
230
231 fn keyed(concurrency_key: &str) -> CreateRunRequest {
232 CreateRunRequest {
233 concurrency_key: Some(concurrency_key.to_string()),
234 ..request(None)
235 }
236 }
237
238 #[test]
239 fn validate_accepts_absent_zero_and_positive_caps() {
240 assert!(request(None).validate().is_ok());
241 assert!(request(Some(Decimal::ZERO)).validate().is_ok());
242 assert!(request(Some(Decimal::new(150, 2))).validate().is_ok());
243 }
244
245 #[test]
246 fn validate_rejects_negative_cap() {
247 let err = request(Some(Decimal::new(-1, 2)))
248 .validate()
249 .expect_err("negative cap must be rejected");
250 assert!(err.contains("max_cost_usd"));
251 }
252
253 #[test]
254 fn max_cost_usd_defaults_to_none_when_absent() {
255 let req: CreateRunRequest =
256 serde_json::from_str(r#"{"workflow":"deploy"}"#).expect("deserialize");
257 assert!(req.max_cost_usd.is_none());
258 }
259
260 #[test]
261 fn max_cost_usd_parses_from_json_number() {
262 let req: CreateRunRequest =
263 serde_json::from_str(r#"{"workflow":"deploy","max_cost_usd":2.5}"#)
264 .expect("deserialize");
265 assert_eq!(req.max_cost_usd, Some(Decimal::new(25, 1)));
266 }
267
268 #[test]
269 fn concurrency_key_defaults_to_none_when_absent() {
270 let req: CreateRunRequest = from_str(r#"{"workflow":"deploy"}"#).expect("deserialize");
271 assert!(req.concurrency_key.is_none());
272 assert!(req.validate().is_ok());
273 }
274
275 #[test]
276 fn concurrency_key_parses_from_json() {
277 let req: CreateRunRequest =
278 from_str(r#"{"workflow":"deploy","concurrency_key":"issue:12"}"#).expect("deserialize");
279 assert_eq!(req.concurrency_key.as_deref(), Some("issue:12"));
280 }
281
282 #[test]
283 fn validate_accepts_a_concurrency_key_up_to_the_limit() {
284 assert!(keyed("issue:12").validate().is_ok());
285 assert!(keyed("cl\u{e9}:\u{e9}lodie").validate().is_ok());
286 let longest = "k".repeat(MAX_CONCURRENCY_KEY_LEN);
287 assert!(keyed(&longest).validate().is_ok());
288 }
289
290 #[test]
291 fn validate_rejects_an_empty_or_blank_concurrency_key() {
292 for key in ["", " ", "\t\n"] {
293 let err = keyed(key)
294 .validate()
295 .expect_err("blank key must be rejected");
296 assert_eq!(err, "concurrency_key must not be empty");
297 }
298 }
299
300 #[test]
301 fn validate_rejects_a_concurrency_key_over_the_limit() {
302 let err = keyed(&"k".repeat(MAX_CONCURRENCY_KEY_LEN + 1))
303 .validate()
304 .expect_err("over-long key must be rejected");
305 assert_eq!(err, "concurrency_key must be at most 255 bytes");
306 }
307
308 #[test]
309 fn validate_counts_the_concurrency_key_limit_in_bytes() {
310 let err = keyed(&"\u{e9}".repeat(128))
312 .validate()
313 .expect_err("the limit is in bytes, not characters");
314 assert!(err.contains("255 bytes"));
315 }
316
317 #[test]
318 fn max_retries_defaults_to_none_when_absent() {
319 let req: CreateRunRequest =
320 serde_json::from_str(r#"{"workflow":"deploy"}"#).expect("deserialize");
321 assert!(req.max_retries.is_none());
322 }
323
324 #[test]
325 fn max_retries_parses_from_json_number() {
326 let req: CreateRunRequest =
327 serde_json::from_str(r#"{"workflow":"deploy","max_retries":3}"#).expect("deserialize");
328 assert_eq!(req.max_retries, Some(3));
329 }
330
331 #[test]
332 fn accepts_a_provider_delivery_id() {
333 assert!(validate_idempotency_key("github:8f4e2a10-1234-4bcd-9876-abcdef012345").is_ok());
334 }
335
336 #[test]
337 fn rejects_an_empty_key() {
338 assert_eq!(
339 validate_idempotency_key(""),
340 Err(IdempotencyKeyError::Empty)
341 );
342 }
343
344 #[test]
345 fn accepts_a_key_at_the_length_limit() {
346 let key = "a".repeat(MAX_IDEMPOTENCY_KEY_LEN);
347 assert!(validate_idempotency_key(&key).is_ok());
348 }
349
350 #[test]
351 fn rejects_a_key_one_byte_over_the_limit() {
352 let key = "a".repeat(MAX_IDEMPOTENCY_KEY_LEN + 1);
353 assert_eq!(
354 validate_idempotency_key(&key),
355 Err(IdempotencyKeyError::TooLong)
356 );
357 }
358
359 #[test]
360 fn rejects_non_ascii() {
361 assert_eq!(
362 validate_idempotency_key("clé-🚀"),
363 Err(IdempotencyKeyError::NotPrintableAscii)
364 );
365 }
366
367 #[test]
368 fn rejects_control_characters() {
369 assert_eq!(
370 validate_idempotency_key("abc\ndef"),
371 Err(IdempotencyKeyError::NotPrintableAscii)
372 );
373 }
374
375 #[test]
376 fn rejects_a_space() {
377 assert_eq!(
379 validate_idempotency_key("abc def"),
380 Err(IdempotencyKeyError::NotPrintableAscii)
381 );
382 }
383
384 #[test]
385 fn error_messages_name_the_broken_rule() {
386 assert!(IdempotencyKeyError::Empty.message().contains("empty"));
387 assert!(
388 IdempotencyKeyError::TooLong
389 .message()
390 .contains(&MAX_IDEMPOTENCY_KEY_LEN.to_string())
391 );
392 assert!(
393 IdempotencyKeyError::NotPrintableAscii
394 .message()
395 .contains("ASCII")
396 );
397 }
398
399 fn limited(concurrency_limits: Vec<ConcurrencyLimit>) -> CreateRunRequest {
400 CreateRunRequest {
401 concurrency_limits,
402 ..request(None)
403 }
404 }
405
406 #[test]
407 fn concurrency_limits_default_to_empty_when_absent() {
408 let req: CreateRunRequest = from_str(r#"{"workflow":"deploy"}"#).expect("deserialize");
409 assert!(req.concurrency_limits.is_empty());
410 assert!(req.validate().is_ok());
411 }
412
413 #[test]
414 fn concurrency_limits_parse_from_json() {
415 let req: CreateRunRequest = from_str(
416 r#"{"workflow":"deploy","concurrency_limits":[{"group":"repo:acme","limit":2}]}"#,
417 )
418 .expect("deserialize");
419 assert_eq!(
420 req.concurrency_limits,
421 vec![ConcurrencyLimit::new("repo:acme", 2)]
422 );
423 assert!(req.validate().is_ok());
424 }
425
426 #[test]
427 fn concurrency_limits_reject_a_negative_limit_at_deserialization() {
428 let res: Result<CreateRunRequest, _> = from_str(
429 r#"{"workflow":"deploy","concurrency_limits":[{"group":"repo:acme","limit":-1}]}"#,
430 );
431 assert!(res.is_err());
432 }
433
434 #[test]
435 fn validate_rejects_invalid_concurrency_limits() {
436 let cases = [
437 (
438 vec![ConcurrencyLimit::new("repo:acme", 0)],
439 "concurrency_limits: concurrency limit for group 'repo:acme' must be at least 1",
440 ),
441 (
442 vec![ConcurrencyLimit::new(" ", 1)],
443 "concurrency_limits: concurrency group must not be empty",
444 ),
445 (
446 vec![
447 ConcurrencyLimit::new("repo:acme", 1),
448 ConcurrencyLimit::new("repo:acme", 2),
449 ],
450 "concurrency_limits: concurrency group 'repo:acme' is listed more than once",
451 ),
452 ];
453 for (limits, expected) in cases {
454 let err = limited(limits)
455 .validate()
456 .expect_err("invalid limits must be rejected");
457 assert_eq!(err, expected);
458 }
459
460 let err = limited(vec![ConcurrencyLimit::new("g".repeat(256), 1)])
461 .validate()
462 .expect_err("over-long group must be rejected");
463 assert!(err.contains("255 bytes"), "{err}");
464 }
465}