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