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_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        // A handler without a declared cap omits the field entirely.
340        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}