1use std::collections::{HashMap, HashSet};
4
5use axum::extract::{Path, State};
6use axum::response::IntoResponse;
7use chrono::{DateTime, Utc};
8use ironflow_auth::extractor::Authenticated;
9use ironflow_store::entities::{MAX_PRIORITY, MIN_PRIORITY};
10use rust_decimal::Decimal;
11use serde::Serialize;
12use serde_json::Value;
13
14use crate::error::ApiError;
15use crate::response::ok;
16use crate::state::AppState;
17
18#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
20#[derive(Debug, Serialize)]
21pub struct SubWorkflowDetail {
22 pub name: String,
24 pub description: String,
26 pub source_code: Option<String>,
28}
29
30#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
32#[derive(Debug, Serialize)]
33pub struct WorkflowDetailResponse {
34 pub name: String,
36 pub description: String,
38 pub source_code: Option<String>,
40 pub sub_workflows: Vec<SubWorkflowDetail>,
42 pub category: Option<String>,
44 pub version: Option<String>,
46 #[serde(default, skip_serializing_if = "Vec::is_empty")]
48 pub compatible_versions: Vec<String>,
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub input_schema: Option<Value>,
52 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
54 pub default_labels: HashMap<String, String>,
55 #[serde(skip_serializing_if = "Option::is_none")]
57 pub schedule: Option<String>,
58 #[cfg_attr(feature = "openapi", schema(value_type = Option<f64>))]
63 #[serde(skip_serializing_if = "Option::is_none")]
64 pub default_max_cost_usd: Option<Decimal>,
65 #[serde(skip_serializing_if = "Option::is_none")]
68 pub paused_at: Option<DateTime<Utc>>,
69 pub priority: i16,
73}
74
75#[cfg_attr(
81 feature = "openapi",
82 utoipa::path(
83 get,
84 path = "/api/v1/workflows/{name}",
85 tags = ["workflows"],
86 params(("name" = String, Path, description = "Workflow name")),
87 responses(
88 (status = 200, description = "Workflow details", body = WorkflowDetailResponse),
89 (status = 401, description = "Unauthorized"),
90 (status = 404, description = "Workflow not found")
91 ),
92 security(("Bearer" = []))
93 )
94)]
95pub async fn get_workflow(
96 _auth: Authenticated,
97 State(state): State<AppState>,
98 Path(name): Path<String>,
99) -> Result<impl IntoResponse, ApiError> {
100 let info = state
101 .engine
102 .handler_info(&name)
103 .ok_or_else(|| ApiError::WorkflowNotFound(name.clone()))?;
104
105 let mut sub_workflows = Vec::new();
106 let mut visited = HashSet::new();
107 visited.insert(name.clone());
108 collect_sub_workflows(
109 &state,
110 &info.sub_workflows,
111 &mut sub_workflows,
112 &mut visited,
113 5,
114 );
115
116 let paused_at = state
117 .store
118 .list_workflow_pauses()
119 .await?
120 .into_iter()
121 .find(|pause| pause.workflow_name == name)
122 .map(|pause| pause.paused_at);
123
124 Ok(ok(WorkflowDetailResponse {
125 name,
126 description: info.description,
127 source_code: info.source_code,
128 sub_workflows,
129 category: info.category,
130 version: info.version,
131 compatible_versions: info.compatible_versions,
132 input_schema: info.input_schema,
133 default_labels: info.default_labels,
134 schedule: info.schedule.map(|s| s.as_str().to_string()),
135 default_max_cost_usd: info.default_max_cost_usd,
136 paused_at,
137 priority: info.priority.clamp(MIN_PRIORITY, MAX_PRIORITY),
138 }))
139}
140
141fn collect_sub_workflows(
142 state: &AppState,
143 names: &[String],
144 result: &mut Vec<SubWorkflowDetail>,
145 visited: &mut HashSet<String>,
146 depth: usize,
147) {
148 if depth == 0 {
149 return;
150 }
151 for sub_name in names {
152 if !visited.insert(sub_name.clone()) {
153 continue;
154 }
155 if let Some(sub_info) = state.engine.handler_info(sub_name) {
156 collect_sub_workflows(state, &sub_info.sub_workflows, result, visited, depth - 1);
157 result.push(SubWorkflowDetail {
158 name: sub_name.clone(),
159 description: sub_info.description,
160 source_code: sub_info.source_code,
161 });
162 }
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use axum::Router;
169 use axum::body::Body;
170 use axum::http::{Request, StatusCode};
171 use axum::routing::get;
172 use http_body_util::BodyExt;
173 use ironflow_core::providers::claude::ClaudeCodeProvider;
174 use ironflow_engine::context::WorkflowContext;
175 use ironflow_engine::engine::Engine;
176 use ironflow_engine::handler::{HandlerFuture, WorkflowHandler};
177 use ironflow_engine::notify::Event;
178 use ironflow_store::memory::InMemoryStore;
179 use serde_json::Value as JsonValue;
180 use std::sync::Arc;
181 use tokio::sync::broadcast;
182 use tower::ServiceExt;
183
184 use super::*;
185 use crate::routes::test_helpers::create_user_auth_header;
186
187 struct DescribedWorkflow;
188 impl WorkflowHandler for DescribedWorkflow {
189 fn name(&self) -> &str {
190 "my-workflow"
191 }
192 fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
193 Box::pin(async move { Ok(()) })
194 }
195 }
196
197 struct CategorizedWorkflow;
198 impl WorkflowHandler for CategorizedWorkflow {
199 fn name(&self) -> &str {
200 "cat-workflow"
201 }
202 fn category(&self) -> Option<&str> {
203 Some("data/etl")
204 }
205 fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
206 Box::pin(async move { Ok(()) })
207 }
208 }
209
210 fn test_state() -> AppState {
211 let store = Arc::new(InMemoryStore::new());
212 Arc::new(InMemoryStore::new());
213 let provider = Arc::new(ClaudeCodeProvider::new());
214 let mut engine = Engine::new(store.clone(), provider);
215 engine.register(DescribedWorkflow).unwrap();
216 engine.register(CategorizedWorkflow).unwrap();
217 let jwt_config = Arc::new(ironflow_auth::jwt::JwtConfig {
218 secret: "test-secret".to_string(),
219 access_token_ttl_secs: 900,
220 refresh_token_ttl_secs: 604800,
221 cookie_domain: None,
222 cookie_secure: false,
223 });
224 let (event_sender, _) = broadcast::channel::<Event>(1);
225 AppState::new(
226 store,
227 Arc::new(engine),
228 jwt_config,
229 "test-worker-token".to_string(),
230 event_sender,
231 )
232 }
233
234 #[tokio::test]
235 async fn get_workflow_found() {
236 let state = test_state();
237 let auth_header = create_user_auth_header(&state, "testuser", false).await;
238 let app = Router::new()
239 .route("/{name}", get(get_workflow))
240 .with_state(state);
241
242 let req = Request::builder()
243 .uri("/my-workflow")
244 .header("authorization", auth_header)
245 .body(Body::empty())
246 .unwrap();
247
248 let resp = app.oneshot(req).await.unwrap();
249 assert_eq!(resp.status(), StatusCode::OK);
250
251 let body = resp.into_body().collect().await.unwrap().to_bytes();
252 let json_val: JsonValue = serde_json::from_slice(&body).unwrap();
253 assert_eq!(json_val["data"]["name"], "my-workflow");
254 }
255
256 #[tokio::test]
257 async fn get_workflow_returns_category_when_set() {
258 let state = test_state();
259 let auth_header = create_user_auth_header(&state, "testuser", false).await;
260 let app = Router::new()
261 .route("/{name}", get(get_workflow))
262 .with_state(state);
263
264 let req = Request::builder()
265 .uri("/cat-workflow")
266 .header("authorization", auth_header)
267 .body(Body::empty())
268 .unwrap();
269
270 let resp = app.oneshot(req).await.unwrap();
271 assert_eq!(resp.status(), StatusCode::OK);
272 let body = resp.into_body().collect().await.unwrap().to_bytes();
273 let json_val: JsonValue = serde_json::from_slice(&body).unwrap();
274 assert_eq!(json_val["data"]["category"], "data/etl");
275 }
276
277 #[tokio::test]
278 async fn get_workflow_category_null_when_uncategorized() {
279 let state = test_state();
280 let auth_header = create_user_auth_header(&state, "testuser", false).await;
281 let app = Router::new()
282 .route("/{name}", get(get_workflow))
283 .with_state(state);
284
285 let req = Request::builder()
286 .uri("/my-workflow")
287 .header("authorization", auth_header)
288 .body(Body::empty())
289 .unwrap();
290
291 let resp = app.oneshot(req).await.unwrap();
292 let body = resp.into_body().collect().await.unwrap().to_bytes();
293 let json_val: JsonValue = serde_json::from_slice(&body).unwrap();
294 assert!(json_val["data"]["category"].is_null());
295 }
296
297 struct CappedWorkflow;
298
299 impl WorkflowHandler for CappedWorkflow {
300 fn name(&self) -> &str {
301 "capped-workflow"
302 }
303 fn default_max_cost_usd(&self) -> Option<Decimal> {
304 Some(Decimal::new(325, 2))
305 }
306 fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
307 Box::pin(async move { Ok(()) })
308 }
309 }
310
311 #[tokio::test]
312 async fn get_workflow_returns_handler_default_max_cost() {
313 let state = {
314 let store = Arc::new(InMemoryStore::new());
315 let provider = Arc::new(ClaudeCodeProvider::new());
316 let mut engine = Engine::new(store.clone(), provider);
317 engine.register(DescribedWorkflow).unwrap();
318 engine.register(CappedWorkflow).unwrap();
319 let jwt_config = Arc::new(ironflow_auth::jwt::JwtConfig {
320 secret: "test-secret".to_string(),
321 access_token_ttl_secs: 900,
322 refresh_token_ttl_secs: 604800,
323 cookie_domain: None,
324 cookie_secure: false,
325 });
326 let (event_sender, _) = broadcast::channel::<Event>(1);
327 AppState::new(
328 store,
329 Arc::new(engine),
330 jwt_config,
331 "test-worker-token".to_string(),
332 event_sender,
333 )
334 };
335 let auth_header = create_user_auth_header(&state, "testuser", false).await;
336 let app = Router::new()
337 .route("/{name}", get(get_workflow))
338 .with_state(state);
339
340 let req = Request::builder()
341 .uri("/capped-workflow")
342 .header("authorization", auth_header.clone())
343 .body(Body::empty())
344 .unwrap();
345
346 let resp = app.clone().oneshot(req).await.unwrap();
347 assert_eq!(resp.status(), StatusCode::OK);
348 let body = resp.into_body().collect().await.unwrap().to_bytes();
349 let json_val: JsonValue = serde_json::from_slice(&body).unwrap();
350 assert_eq!(json_val["data"]["default_max_cost_usd"], 3.25);
351
352 let req = Request::builder()
354 .uri("/my-workflow")
355 .header("authorization", auth_header)
356 .body(Body::empty())
357 .unwrap();
358
359 let resp = app.oneshot(req).await.unwrap();
360 let body = resp.into_body().collect().await.unwrap().to_bytes();
361 let json_val: JsonValue = serde_json::from_slice(&body).unwrap();
362 assert!(json_val["data"].get("default_max_cost_usd").is_none());
363 }
364
365 struct ScheduledWorkflow {
366 schedule: ironflow_engine::prelude::CronSchedule,
367 }
368 impl ScheduledWorkflow {
369 fn new() -> Self {
370 Self {
371 schedule: ironflow_engine::prelude::CronSchedule::new("0 0 * * * *").unwrap(),
372 }
373 }
374 }
375 impl WorkflowHandler for ScheduledWorkflow {
376 fn name(&self) -> &str {
377 "sched-workflow"
378 }
379 fn schedule(&self) -> Option<&ironflow_engine::prelude::CronSchedule> {
380 Some(&self.schedule)
381 }
382 fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
383 Box::pin(async move { Ok(()) })
384 }
385 }
386
387 fn test_state_with_schedule() -> AppState {
388 let store = Arc::new(InMemoryStore::new());
389 let provider = Arc::new(ClaudeCodeProvider::new());
390 let mut engine = Engine::new(store.clone(), provider);
391 engine.register(DescribedWorkflow).unwrap();
392 engine.register(ScheduledWorkflow::new()).unwrap();
393 let jwt_config = Arc::new(ironflow_auth::jwt::JwtConfig {
394 secret: "test-secret".to_string(),
395 access_token_ttl_secs: 900,
396 refresh_token_ttl_secs: 604800,
397 cookie_domain: None,
398 cookie_secure: false,
399 });
400 let (event_sender, _) = broadcast::channel::<Event>(1);
401 AppState::new(
402 store,
403 Arc::new(engine),
404 jwt_config,
405 "test-worker-token".to_string(),
406 event_sender,
407 )
408 }
409
410 #[tokio::test]
411 async fn get_workflow_returns_schedule_when_set() {
412 let state = test_state_with_schedule();
413 let auth_header = create_user_auth_header(&state, "testuser", false).await;
414 let app = Router::new()
415 .route("/{name}", get(get_workflow))
416 .with_state(state);
417
418 let req = Request::builder()
419 .uri("/sched-workflow")
420 .header("authorization", auth_header)
421 .body(Body::empty())
422 .unwrap();
423
424 let resp = app.oneshot(req).await.unwrap();
425 assert_eq!(resp.status(), StatusCode::OK);
426 let body = resp.into_body().collect().await.unwrap().to_bytes();
427 let json_val: JsonValue = serde_json::from_slice(&body).unwrap();
428 assert_eq!(json_val["data"]["schedule"], "0 0 * * * *");
429 }
430
431 #[tokio::test]
432 async fn get_workflow_schedule_null_when_unscheduled() {
433 let state = test_state_with_schedule();
434 let auth_header = create_user_auth_header(&state, "testuser", false).await;
435 let app = Router::new()
436 .route("/{name}", get(get_workflow))
437 .with_state(state);
438
439 let req = Request::builder()
440 .uri("/my-workflow")
441 .header("authorization", auth_header)
442 .body(Body::empty())
443 .unwrap();
444
445 let resp = app.oneshot(req).await.unwrap();
446 let body = resp.into_body().collect().await.unwrap().to_bytes();
447 let json_val: JsonValue = serde_json::from_slice(&body).unwrap();
448 assert!(json_val["data"]["schedule"].is_null());
449 }
450
451 struct PrioritizedWorkflow {
452 name: &'static str,
453 priority: i16,
454 }
455 impl WorkflowHandler for PrioritizedWorkflow {
456 fn name(&self) -> &str {
457 self.name
458 }
459 fn priority(&self) -> i16 {
460 self.priority
461 }
462 fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
463 Box::pin(async move { Ok(()) })
464 }
465 }
466
467 fn test_state_with_priorities() -> AppState {
468 let store = Arc::new(InMemoryStore::new());
469 let provider = Arc::new(ClaudeCodeProvider::new());
470 let mut engine = Engine::new(store.clone(), provider);
471 engine.register(DescribedWorkflow).unwrap();
472 for (name, priority) in [
473 ("prio-workflow", 50),
474 ("extreme-workflow", 500),
475 ("abyss-workflow", -500),
476 ] {
477 engine
478 .register(PrioritizedWorkflow { name, priority })
479 .unwrap();
480 }
481 let jwt_config = Arc::new(ironflow_auth::jwt::JwtConfig {
482 secret: "test-secret".to_string(),
483 access_token_ttl_secs: 900,
484 refresh_token_ttl_secs: 604800,
485 cookie_domain: None,
486 cookie_secure: false,
487 });
488 let (event_sender, _) = broadcast::channel::<Event>(1);
489 AppState::new(
490 store,
491 Arc::new(engine),
492 jwt_config,
493 "test-worker-token".to_string(),
494 event_sender,
495 )
496 }
497
498 async fn get_workflow_json(uri: &str) -> JsonValue {
499 let state = test_state_with_priorities();
500 let auth_header = create_user_auth_header(&state, "testuser", false).await;
501 let app = Router::new()
502 .route("/{name}", get(get_workflow))
503 .with_state(state);
504
505 let req = Request::builder()
506 .uri(uri)
507 .header("authorization", auth_header)
508 .body(Body::empty())
509 .unwrap();
510
511 let resp = app.oneshot(req).await.unwrap();
512 assert_eq!(resp.status(), StatusCode::OK);
513 let body = resp.into_body().collect().await.unwrap().to_bytes();
514 serde_json::from_slice(&body).unwrap()
515 }
516
517 #[tokio::test]
518 async fn get_workflow_returns_handler_priority() {
519 let json_val = get_workflow_json("/prio-workflow").await;
520 assert_eq!(json_val["data"]["priority"], 50);
521 }
522
523 #[tokio::test]
524 async fn get_workflow_priority_defaults_to_zero() {
525 let json_val = get_workflow_json("/my-workflow").await;
526 assert!(json_val["data"].get("priority").is_some());
527 assert_eq!(json_val["data"]["priority"], 0);
528 }
529
530 #[tokio::test]
531 async fn get_workflow_priority_is_clamped() {
532 let json_val = get_workflow_json("/extreme-workflow").await;
533 assert_eq!(json_val["data"]["priority"], 100);
534
535 let json_val = get_workflow_json("/abyss-workflow").await;
536 assert_eq!(json_val["data"]["priority"], -100);
537 }
538
539 #[tokio::test]
540 async fn get_workflow_not_found() {
541 let state = test_state();
542 let auth_header = create_user_auth_header(&state, "testuser", false).await;
543 let app = Router::new()
544 .route("/{name}", get(get_workflow))
545 .with_state(state);
546
547 let req = Request::builder()
548 .uri("/nonexistent")
549 .header("authorization", auth_header)
550 .body(Body::empty())
551 .unwrap();
552
553 let resp = app.oneshot(req).await.unwrap();
554 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
555 }
556}