Skip to main content

ironflow_api/routes/
get_workflow.rs

1//! `GET /api/v1/workflows/:name` — Get workflow details.
2
3use 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/// Sub-workflow detail included in the workflow response.
17#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
18#[derive(Debug, Serialize)]
19pub struct SubWorkflowDetail {
20    /// Sub-workflow name.
21    pub name: String,
22    /// Human-readable description.
23    pub description: String,
24    /// Optional Rust source code of the handler.
25    pub source_code: Option<String>,
26}
27
28/// Workflow detail response.
29#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
30#[derive(Debug, Serialize)]
31pub struct WorkflowDetailResponse {
32    /// Workflow name.
33    pub name: String,
34    /// Human-readable description.
35    pub description: String,
36    /// Optional Rust source code of the handler.
37    pub source_code: Option<String>,
38    /// Sub-workflows invoked by this handler (recursive, depth-limited).
39    pub sub_workflows: Vec<SubWorkflowDetail>,
40    /// Optional `/`-separated category path used to group workflows.
41    pub category: Option<String>,
42    /// Current handler version.
43    pub version: Option<String>,
44    /// Versions accepted for replay without `force`.
45    #[serde(default, skip_serializing_if = "Vec::is_empty")]
46    pub compatible_versions: Vec<String>,
47    /// JSON Schema describing the expected input payload.
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub input_schema: Option<Value>,
50    /// Labels automatically applied to every run of this workflow.
51    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
52    pub default_labels: HashMap<String, String>,
53    /// Optional 6-field cron expression for automatic execution.
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub schedule: Option<String>,
56    /// Default cumulative cost cap applied to runs of this workflow, in USD.
57    ///
58    /// Overridden by a cap supplied at run creation. `None` means the workflow
59    /// declares no default and falls back to the server default (if any).
60    #[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/// Get details about a registered workflow.
66///
67/// # Errors
68///
69/// - 404 if the workflow is not registered
70#[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        // A handler without a declared cap omits the field entirely.
333        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}