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