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_auth::jwt::AccessToken;
154 use ironflow_core::providers::claude::ClaudeCodeProvider;
155 use ironflow_engine::context::WorkflowContext;
156 use ironflow_engine::engine::Engine;
157 use ironflow_engine::handler::{HandlerFuture, WorkflowHandler};
158 use ironflow_engine::notify::Event;
159 use ironflow_store::memory::InMemoryStore;
160 use serde_json::Value as JsonValue;
161 use std::sync::Arc;
162 use tokio::sync::broadcast;
163 use tower::ServiceExt;
164 use uuid::Uuid;
165
166 use super::*;
167
168 struct DescribedWorkflow;
169 impl WorkflowHandler for DescribedWorkflow {
170 fn name(&self) -> &str {
171 "my-workflow"
172 }
173 fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
174 Box::pin(async move { Ok(()) })
175 }
176 }
177
178 struct CategorizedWorkflow;
179 impl WorkflowHandler for CategorizedWorkflow {
180 fn name(&self) -> &str {
181 "cat-workflow"
182 }
183 fn category(&self) -> Option<&str> {
184 Some("data/etl")
185 }
186 fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
187 Box::pin(async move { Ok(()) })
188 }
189 }
190
191 fn test_state() -> AppState {
192 let store = Arc::new(InMemoryStore::new());
193 Arc::new(InMemoryStore::new());
194 let provider = Arc::new(ClaudeCodeProvider::new());
195 let mut engine = Engine::new(store.clone(), provider);
196 engine.register(DescribedWorkflow).unwrap();
197 engine.register(CategorizedWorkflow).unwrap();
198 let jwt_config = Arc::new(ironflow_auth::jwt::JwtConfig {
199 secret: "test-secret".to_string(),
200 access_token_ttl_secs: 900,
201 refresh_token_ttl_secs: 604800,
202 cookie_domain: None,
203 cookie_secure: false,
204 });
205 let (event_sender, _) = broadcast::channel::<Event>(1);
206 AppState::new(
207 store,
208 Arc::new(engine),
209 jwt_config,
210 "test-worker-token".to_string(),
211 event_sender,
212 )
213 }
214
215 fn make_auth_header(state: &AppState) -> String {
216 let user_id = Uuid::now_v7();
217 let token = AccessToken::for_user(user_id, "testuser", false, &state.jwt_config).unwrap();
218 format!("Bearer {}", token.0)
219 }
220
221 #[tokio::test]
222 async fn get_workflow_found() {
223 let state = test_state();
224 let auth_header = make_auth_header(&state);
225 let app = Router::new()
226 .route("/{name}", get(get_workflow))
227 .with_state(state);
228
229 let req = Request::builder()
230 .uri("/my-workflow")
231 .header("authorization", auth_header)
232 .body(Body::empty())
233 .unwrap();
234
235 let resp = app.oneshot(req).await.unwrap();
236 assert_eq!(resp.status(), StatusCode::OK);
237
238 let body = resp.into_body().collect().await.unwrap().to_bytes();
239 let json_val: JsonValue = serde_json::from_slice(&body).unwrap();
240 assert_eq!(json_val["data"]["name"], "my-workflow");
241 }
242
243 #[tokio::test]
244 async fn get_workflow_returns_category_when_set() {
245 let state = test_state();
246 let auth_header = make_auth_header(&state);
247 let app = Router::new()
248 .route("/{name}", get(get_workflow))
249 .with_state(state);
250
251 let req = Request::builder()
252 .uri("/cat-workflow")
253 .header("authorization", auth_header)
254 .body(Body::empty())
255 .unwrap();
256
257 let resp = app.oneshot(req).await.unwrap();
258 assert_eq!(resp.status(), StatusCode::OK);
259 let body = resp.into_body().collect().await.unwrap().to_bytes();
260 let json_val: JsonValue = serde_json::from_slice(&body).unwrap();
261 assert_eq!(json_val["data"]["category"], "data/etl");
262 }
263
264 #[tokio::test]
265 async fn get_workflow_category_null_when_uncategorized() {
266 let state = test_state();
267 let auth_header = make_auth_header(&state);
268 let app = Router::new()
269 .route("/{name}", get(get_workflow))
270 .with_state(state);
271
272 let req = Request::builder()
273 .uri("/my-workflow")
274 .header("authorization", auth_header)
275 .body(Body::empty())
276 .unwrap();
277
278 let resp = app.oneshot(req).await.unwrap();
279 let body = resp.into_body().collect().await.unwrap().to_bytes();
280 let json_val: JsonValue = serde_json::from_slice(&body).unwrap();
281 assert!(json_val["data"]["category"].is_null());
282 }
283
284 struct CappedWorkflow;
285
286 impl WorkflowHandler for CappedWorkflow {
287 fn name(&self) -> &str {
288 "capped-workflow"
289 }
290 fn default_max_cost_usd(&self) -> Option<Decimal> {
291 Some(Decimal::new(325, 2))
292 }
293 fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
294 Box::pin(async move { Ok(()) })
295 }
296 }
297
298 #[tokio::test]
299 async fn get_workflow_returns_handler_default_max_cost() {
300 let state = {
301 let store = Arc::new(InMemoryStore::new());
302 let provider = Arc::new(ClaudeCodeProvider::new());
303 let mut engine = Engine::new(store.clone(), provider);
304 engine.register(DescribedWorkflow).unwrap();
305 engine.register(CappedWorkflow).unwrap();
306 let jwt_config = Arc::new(ironflow_auth::jwt::JwtConfig {
307 secret: "test-secret".to_string(),
308 access_token_ttl_secs: 900,
309 refresh_token_ttl_secs: 604800,
310 cookie_domain: None,
311 cookie_secure: false,
312 });
313 let (event_sender, _) = broadcast::channel::<Event>(1);
314 AppState::new(
315 store,
316 Arc::new(engine),
317 jwt_config,
318 "test-worker-token".to_string(),
319 event_sender,
320 )
321 };
322 let auth_header = make_auth_header(&state);
323 let app = Router::new()
324 .route("/{name}", get(get_workflow))
325 .with_state(state);
326
327 let req = Request::builder()
328 .uri("/capped-workflow")
329 .header("authorization", auth_header.clone())
330 .body(Body::empty())
331 .unwrap();
332
333 let resp = app.clone().oneshot(req).await.unwrap();
334 assert_eq!(resp.status(), StatusCode::OK);
335 let body = resp.into_body().collect().await.unwrap().to_bytes();
336 let json_val: JsonValue = serde_json::from_slice(&body).unwrap();
337 assert_eq!(json_val["data"]["default_max_cost_usd"], 3.25);
338
339 let req = Request::builder()
341 .uri("/my-workflow")
342 .header("authorization", auth_header)
343 .body(Body::empty())
344 .unwrap();
345
346 let resp = app.oneshot(req).await.unwrap();
347 let body = resp.into_body().collect().await.unwrap().to_bytes();
348 let json_val: JsonValue = serde_json::from_slice(&body).unwrap();
349 assert!(json_val["data"].get("default_max_cost_usd").is_none());
350 }
351
352 struct ScheduledWorkflow {
353 schedule: ironflow_engine::prelude::CronSchedule,
354 }
355 impl ScheduledWorkflow {
356 fn new() -> Self {
357 Self {
358 schedule: ironflow_engine::prelude::CronSchedule::new("0 0 * * * *").unwrap(),
359 }
360 }
361 }
362 impl WorkflowHandler for ScheduledWorkflow {
363 fn name(&self) -> &str {
364 "sched-workflow"
365 }
366 fn schedule(&self) -> Option<&ironflow_engine::prelude::CronSchedule> {
367 Some(&self.schedule)
368 }
369 fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
370 Box::pin(async move { Ok(()) })
371 }
372 }
373
374 fn test_state_with_schedule() -> AppState {
375 let store = Arc::new(InMemoryStore::new());
376 let provider = Arc::new(ClaudeCodeProvider::new());
377 let mut engine = Engine::new(store.clone(), provider);
378 engine.register(DescribedWorkflow).unwrap();
379 engine.register(ScheduledWorkflow::new()).unwrap();
380 let jwt_config = Arc::new(ironflow_auth::jwt::JwtConfig {
381 secret: "test-secret".to_string(),
382 access_token_ttl_secs: 900,
383 refresh_token_ttl_secs: 604800,
384 cookie_domain: None,
385 cookie_secure: false,
386 });
387 let (event_sender, _) = broadcast::channel::<Event>(1);
388 AppState::new(
389 store,
390 Arc::new(engine),
391 jwt_config,
392 "test-worker-token".to_string(),
393 event_sender,
394 )
395 }
396
397 #[tokio::test]
398 async fn get_workflow_returns_schedule_when_set() {
399 let state = test_state_with_schedule();
400 let auth_header = make_auth_header(&state);
401 let app = Router::new()
402 .route("/{name}", get(get_workflow))
403 .with_state(state);
404
405 let req = Request::builder()
406 .uri("/sched-workflow")
407 .header("authorization", auth_header)
408 .body(Body::empty())
409 .unwrap();
410
411 let resp = app.oneshot(req).await.unwrap();
412 assert_eq!(resp.status(), StatusCode::OK);
413 let body = resp.into_body().collect().await.unwrap().to_bytes();
414 let json_val: JsonValue = serde_json::from_slice(&body).unwrap();
415 assert_eq!(json_val["data"]["schedule"], "0 0 * * * *");
416 }
417
418 #[tokio::test]
419 async fn get_workflow_schedule_null_when_unscheduled() {
420 let state = test_state_with_schedule();
421 let auth_header = make_auth_header(&state);
422 let app = Router::new()
423 .route("/{name}", get(get_workflow))
424 .with_state(state);
425
426 let req = Request::builder()
427 .uri("/my-workflow")
428 .header("authorization", auth_header)
429 .body(Body::empty())
430 .unwrap();
431
432 let resp = app.oneshot(req).await.unwrap();
433 let body = resp.into_body().collect().await.unwrap().to_bytes();
434 let json_val: JsonValue = serde_json::from_slice(&body).unwrap();
435 assert!(json_val["data"]["schedule"].is_null());
436 }
437
438 #[tokio::test]
439 async fn get_workflow_not_found() {
440 let state = test_state();
441 let auth_header = make_auth_header(&state);
442 let app = Router::new()
443 .route("/{name}", get(get_workflow))
444 .with_state(state);
445
446 let req = Request::builder()
447 .uri("/nonexistent")
448 .header("authorization", auth_header)
449 .body(Body::empty())
450 .unwrap();
451
452 let resp = app.oneshot(req).await.unwrap();
453 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
454 }
455}