ironflow_api/entities/
create_run.rs1use 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#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
30#[derive(Debug, Deserialize)]
31pub struct CreateRunRequest {
32 pub workflow: String,
34 #[cfg_attr(feature = "openapi", schema(value_type = Option<std::collections::HashMap<String, serde_json::Value>>))]
36 pub payload: Option<Value>,
37 #[serde(default)]
39 pub labels: Option<HashMap<String, String>>,
40 #[serde(default)]
42 pub scheduled_at: Option<DateTime<Utc>>,
43 #[serde(default)]
52 pub max_retries: Option<u32>,
53 #[cfg_attr(feature = "openapi", schema(value_type = Option<f64>))]
58 #[serde(default)]
59 pub max_cost_usd: Option<Decimal>,
60}
61
62impl CreateRunRequest {
63 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum IdempotencyKeyError {
109 Empty,
111 TooLong,
113 NotPrintableAscii,
115}
116
117impl IdempotencyKeyError {
118 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
140pub 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 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}