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