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 ironflow_store::entities::{MAX_PRIORITY, MIN_PRIORITY};
10use rust_decimal::Decimal;
11use serde::Serialize;
12use serde_json::Value;
13
14use crate::error::ApiError;
15use crate::response::ok;
16use crate::state::AppState;
17
18/// Sub-workflow detail included in the workflow response.
19#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
20#[derive(Debug, Serialize)]
21pub struct SubWorkflowDetail {
22    /// Sub-workflow name.
23    pub name: String,
24    /// Human-readable description.
25    pub description: String,
26    /// Optional Rust source code of the handler.
27    pub source_code: Option<String>,
28}
29
30/// Workflow detail response.
31#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
32#[derive(Debug, Serialize)]
33pub struct WorkflowDetailResponse {
34    /// Workflow name.
35    pub name: String,
36    /// Human-readable description.
37    pub description: String,
38    /// Optional Rust source code of the handler.
39    pub source_code: Option<String>,
40    /// Sub-workflows invoked by this handler (recursive, depth-limited).
41    pub sub_workflows: Vec<SubWorkflowDetail>,
42    /// Optional `/`-separated category path used to group workflows.
43    pub category: Option<String>,
44    /// Current handler version.
45    pub version: Option<String>,
46    /// Versions accepted for replay without `force`.
47    #[serde(default, skip_serializing_if = "Vec::is_empty")]
48    pub compatible_versions: Vec<String>,
49    /// JSON Schema describing the expected input payload.
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub input_schema: Option<Value>,
52    /// Labels automatically applied to every run of this workflow.
53    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
54    pub default_labels: HashMap<String, String>,
55    /// Optional 6-field cron expression for automatic execution.
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub schedule: Option<String>,
58    /// Default cumulative cost cap applied to runs of this workflow, in USD.
59    ///
60    /// Overridden by a cap supplied at run creation. `None` means the workflow
61    /// declares no default and falls back to the server default (if any).
62    #[cfg_attr(feature = "openapi", schema(value_type = Option<f64>))]
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub default_max_cost_usd: Option<Decimal>,
65    /// When the workflow was paused: its queued runs are not picked up until
66    /// it is resumed. Omitted when the workflow is not paused.
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub paused_at: Option<DateTime<Utc>>,
69    /// Default queue priority of the runs of this workflow, from -100 to 100.
70    /// Clamped like the run creation path. Always present, 0 when the handler
71    /// declares none.
72    pub priority: i16,
73}
74
75/// Get details about a registered workflow.
76///
77/// # Errors
78///
79/// - 404 if the workflow is not registered
80#[cfg_attr(
81    feature = "openapi",
82    utoipa::path(
83        get,
84        path = "/api/v1/workflows/{name}",
85        tags = ["workflows"],
86        params(("name" = String, Path, description = "Workflow name")),
87        responses(
88            (status = 200, description = "Workflow details", body = WorkflowDetailResponse),
89            (status = 401, description = "Unauthorized"),
90            (status = 404, description = "Workflow not found")
91        ),
92        security(("Bearer" = []))
93    )
94)]
95pub async fn get_workflow(
96    _auth: Authenticated,
97    State(state): State<AppState>,
98    Path(name): Path<String>,
99) -> Result<impl IntoResponse, ApiError> {
100    let info = state
101        .engine
102        .handler_info(&name)
103        .ok_or_else(|| ApiError::WorkflowNotFound(name.clone()))?;
104
105    let mut sub_workflows = Vec::new();
106    let mut visited = HashSet::new();
107    visited.insert(name.clone());
108    collect_sub_workflows(
109        &state,
110        &info.sub_workflows,
111        &mut sub_workflows,
112        &mut visited,
113        5,
114    );
115
116    let paused_at = state
117        .store
118        .list_workflow_pauses()
119        .await?
120        .into_iter()
121        .find(|pause| pause.workflow_name == name)
122        .map(|pause| pause.paused_at);
123
124    Ok(ok(WorkflowDetailResponse {
125        name,
126        description: info.description,
127        source_code: info.source_code,
128        sub_workflows,
129        category: info.category,
130        version: info.version,
131        compatible_versions: info.compatible_versions,
132        input_schema: info.input_schema,
133        default_labels: info.default_labels,
134        schedule: info.schedule.map(|s| s.as_str().to_string()),
135        default_max_cost_usd: info.default_max_cost_usd,
136        paused_at,
137        priority: info.priority.clamp(MIN_PRIORITY, MAX_PRIORITY),
138    }))
139}
140
141fn collect_sub_workflows(
142    state: &AppState,
143    names: &[String],
144    result: &mut Vec<SubWorkflowDetail>,
145    visited: &mut HashSet<String>,
146    depth: usize,
147) {
148    if depth == 0 {
149        return;
150    }
151    for sub_name in names {
152        if !visited.insert(sub_name.clone()) {
153            continue;
154        }
155        if let Some(sub_info) = state.engine.handler_info(sub_name) {
156            collect_sub_workflows(state, &sub_info.sub_workflows, result, visited, depth - 1);
157            result.push(SubWorkflowDetail {
158                name: sub_name.clone(),
159                description: sub_info.description,
160                source_code: sub_info.source_code,
161            });
162        }
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use axum::Router;
169    use axum::body::Body;
170    use axum::http::{Request, StatusCode};
171    use axum::routing::get;
172    use http_body_util::BodyExt;
173    use ironflow_core::providers::claude::ClaudeCodeProvider;
174    use ironflow_engine::context::WorkflowContext;
175    use ironflow_engine::engine::Engine;
176    use ironflow_engine::handler::{HandlerFuture, WorkflowHandler};
177    use ironflow_engine::notify::Event;
178    use ironflow_store::memory::InMemoryStore;
179    use serde_json::Value as JsonValue;
180    use std::sync::Arc;
181    use tokio::sync::broadcast;
182    use tower::ServiceExt;
183
184    use super::*;
185    use crate::routes::test_helpers::create_user_auth_header;
186
187    struct DescribedWorkflow;
188    impl WorkflowHandler for DescribedWorkflow {
189        fn name(&self) -> &str {
190            "my-workflow"
191        }
192        fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
193            Box::pin(async move { Ok(()) })
194        }
195    }
196
197    struct CategorizedWorkflow;
198    impl WorkflowHandler for CategorizedWorkflow {
199        fn name(&self) -> &str {
200            "cat-workflow"
201        }
202        fn category(&self) -> Option<&str> {
203            Some("data/etl")
204        }
205        fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
206            Box::pin(async move { Ok(()) })
207        }
208    }
209
210    fn test_state() -> AppState {
211        let store = Arc::new(InMemoryStore::new());
212        Arc::new(InMemoryStore::new());
213        let provider = Arc::new(ClaudeCodeProvider::new());
214        let mut engine = Engine::new(store.clone(), provider);
215        engine.register(DescribedWorkflow).unwrap();
216        engine.register(CategorizedWorkflow).unwrap();
217        let jwt_config = Arc::new(ironflow_auth::jwt::JwtConfig {
218            secret: "test-secret".to_string(),
219            access_token_ttl_secs: 900,
220            refresh_token_ttl_secs: 604800,
221            cookie_domain: None,
222            cookie_secure: false,
223        });
224        let (event_sender, _) = broadcast::channel::<Event>(1);
225        AppState::new(
226            store,
227            Arc::new(engine),
228            jwt_config,
229            "test-worker-token".to_string(),
230            event_sender,
231        )
232    }
233
234    #[tokio::test]
235    async fn get_workflow_found() {
236        let state = test_state();
237        let auth_header = create_user_auth_header(&state, "testuser", false).await;
238        let app = Router::new()
239            .route("/{name}", get(get_workflow))
240            .with_state(state);
241
242        let req = Request::builder()
243            .uri("/my-workflow")
244            .header("authorization", auth_header)
245            .body(Body::empty())
246            .unwrap();
247
248        let resp = app.oneshot(req).await.unwrap();
249        assert_eq!(resp.status(), StatusCode::OK);
250
251        let body = resp.into_body().collect().await.unwrap().to_bytes();
252        let json_val: JsonValue = serde_json::from_slice(&body).unwrap();
253        assert_eq!(json_val["data"]["name"], "my-workflow");
254    }
255
256    #[tokio::test]
257    async fn get_workflow_returns_category_when_set() {
258        let state = test_state();
259        let auth_header = create_user_auth_header(&state, "testuser", false).await;
260        let app = Router::new()
261            .route("/{name}", get(get_workflow))
262            .with_state(state);
263
264        let req = Request::builder()
265            .uri("/cat-workflow")
266            .header("authorization", auth_header)
267            .body(Body::empty())
268            .unwrap();
269
270        let resp = app.oneshot(req).await.unwrap();
271        assert_eq!(resp.status(), StatusCode::OK);
272        let body = resp.into_body().collect().await.unwrap().to_bytes();
273        let json_val: JsonValue = serde_json::from_slice(&body).unwrap();
274        assert_eq!(json_val["data"]["category"], "data/etl");
275    }
276
277    #[tokio::test]
278    async fn get_workflow_category_null_when_uncategorized() {
279        let state = test_state();
280        let auth_header = create_user_auth_header(&state, "testuser", false).await;
281        let app = Router::new()
282            .route("/{name}", get(get_workflow))
283            .with_state(state);
284
285        let req = Request::builder()
286            .uri("/my-workflow")
287            .header("authorization", auth_header)
288            .body(Body::empty())
289            .unwrap();
290
291        let resp = app.oneshot(req).await.unwrap();
292        let body = resp.into_body().collect().await.unwrap().to_bytes();
293        let json_val: JsonValue = serde_json::from_slice(&body).unwrap();
294        assert!(json_val["data"]["category"].is_null());
295    }
296
297    struct CappedWorkflow;
298
299    impl WorkflowHandler for CappedWorkflow {
300        fn name(&self) -> &str {
301            "capped-workflow"
302        }
303        fn default_max_cost_usd(&self) -> Option<Decimal> {
304            Some(Decimal::new(325, 2))
305        }
306        fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
307            Box::pin(async move { Ok(()) })
308        }
309    }
310
311    #[tokio::test]
312    async fn get_workflow_returns_handler_default_max_cost() {
313        let state = {
314            let store = Arc::new(InMemoryStore::new());
315            let provider = Arc::new(ClaudeCodeProvider::new());
316            let mut engine = Engine::new(store.clone(), provider);
317            engine.register(DescribedWorkflow).unwrap();
318            engine.register(CappedWorkflow).unwrap();
319            let jwt_config = Arc::new(ironflow_auth::jwt::JwtConfig {
320                secret: "test-secret".to_string(),
321                access_token_ttl_secs: 900,
322                refresh_token_ttl_secs: 604800,
323                cookie_domain: None,
324                cookie_secure: false,
325            });
326            let (event_sender, _) = broadcast::channel::<Event>(1);
327            AppState::new(
328                store,
329                Arc::new(engine),
330                jwt_config,
331                "test-worker-token".to_string(),
332                event_sender,
333            )
334        };
335        let auth_header = create_user_auth_header(&state, "testuser", false).await;
336        let app = Router::new()
337            .route("/{name}", get(get_workflow))
338            .with_state(state);
339
340        let req = Request::builder()
341            .uri("/capped-workflow")
342            .header("authorization", auth_header.clone())
343            .body(Body::empty())
344            .unwrap();
345
346        let resp = app.clone().oneshot(req).await.unwrap();
347        assert_eq!(resp.status(), StatusCode::OK);
348        let body = resp.into_body().collect().await.unwrap().to_bytes();
349        let json_val: JsonValue = serde_json::from_slice(&body).unwrap();
350        assert_eq!(json_val["data"]["default_max_cost_usd"], 3.25);
351
352        // A handler without a declared cap omits the field entirely.
353        let req = Request::builder()
354            .uri("/my-workflow")
355            .header("authorization", auth_header)
356            .body(Body::empty())
357            .unwrap();
358
359        let resp = app.oneshot(req).await.unwrap();
360        let body = resp.into_body().collect().await.unwrap().to_bytes();
361        let json_val: JsonValue = serde_json::from_slice(&body).unwrap();
362        assert!(json_val["data"].get("default_max_cost_usd").is_none());
363    }
364
365    struct ScheduledWorkflow {
366        schedule: ironflow_engine::prelude::CronSchedule,
367    }
368    impl ScheduledWorkflow {
369        fn new() -> Self {
370            Self {
371                schedule: ironflow_engine::prelude::CronSchedule::new("0 0 * * * *").unwrap(),
372            }
373        }
374    }
375    impl WorkflowHandler for ScheduledWorkflow {
376        fn name(&self) -> &str {
377            "sched-workflow"
378        }
379        fn schedule(&self) -> Option<&ironflow_engine::prelude::CronSchedule> {
380            Some(&self.schedule)
381        }
382        fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
383            Box::pin(async move { Ok(()) })
384        }
385    }
386
387    fn test_state_with_schedule() -> AppState {
388        let store = Arc::new(InMemoryStore::new());
389        let provider = Arc::new(ClaudeCodeProvider::new());
390        let mut engine = Engine::new(store.clone(), provider);
391        engine.register(DescribedWorkflow).unwrap();
392        engine.register(ScheduledWorkflow::new()).unwrap();
393        let jwt_config = Arc::new(ironflow_auth::jwt::JwtConfig {
394            secret: "test-secret".to_string(),
395            access_token_ttl_secs: 900,
396            refresh_token_ttl_secs: 604800,
397            cookie_domain: None,
398            cookie_secure: false,
399        });
400        let (event_sender, _) = broadcast::channel::<Event>(1);
401        AppState::new(
402            store,
403            Arc::new(engine),
404            jwt_config,
405            "test-worker-token".to_string(),
406            event_sender,
407        )
408    }
409
410    #[tokio::test]
411    async fn get_workflow_returns_schedule_when_set() {
412        let state = test_state_with_schedule();
413        let auth_header = create_user_auth_header(&state, "testuser", false).await;
414        let app = Router::new()
415            .route("/{name}", get(get_workflow))
416            .with_state(state);
417
418        let req = Request::builder()
419            .uri("/sched-workflow")
420            .header("authorization", auth_header)
421            .body(Body::empty())
422            .unwrap();
423
424        let resp = app.oneshot(req).await.unwrap();
425        assert_eq!(resp.status(), StatusCode::OK);
426        let body = resp.into_body().collect().await.unwrap().to_bytes();
427        let json_val: JsonValue = serde_json::from_slice(&body).unwrap();
428        assert_eq!(json_val["data"]["schedule"], "0 0 * * * *");
429    }
430
431    #[tokio::test]
432    async fn get_workflow_schedule_null_when_unscheduled() {
433        let state = test_state_with_schedule();
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("/my-workflow")
441            .header("authorization", auth_header)
442            .body(Body::empty())
443            .unwrap();
444
445        let resp = app.oneshot(req).await.unwrap();
446        let body = resp.into_body().collect().await.unwrap().to_bytes();
447        let json_val: JsonValue = serde_json::from_slice(&body).unwrap();
448        assert!(json_val["data"]["schedule"].is_null());
449    }
450
451    struct PrioritizedWorkflow {
452        name: &'static str,
453        priority: i16,
454    }
455    impl WorkflowHandler for PrioritizedWorkflow {
456        fn name(&self) -> &str {
457            self.name
458        }
459        fn priority(&self) -> i16 {
460            self.priority
461        }
462        fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
463            Box::pin(async move { Ok(()) })
464        }
465    }
466
467    fn test_state_with_priorities() -> AppState {
468        let store = Arc::new(InMemoryStore::new());
469        let provider = Arc::new(ClaudeCodeProvider::new());
470        let mut engine = Engine::new(store.clone(), provider);
471        engine.register(DescribedWorkflow).unwrap();
472        for (name, priority) in [
473            ("prio-workflow", 50),
474            ("extreme-workflow", 500),
475            ("abyss-workflow", -500),
476        ] {
477            engine
478                .register(PrioritizedWorkflow { name, priority })
479                .unwrap();
480        }
481        let jwt_config = Arc::new(ironflow_auth::jwt::JwtConfig {
482            secret: "test-secret".to_string(),
483            access_token_ttl_secs: 900,
484            refresh_token_ttl_secs: 604800,
485            cookie_domain: None,
486            cookie_secure: false,
487        });
488        let (event_sender, _) = broadcast::channel::<Event>(1);
489        AppState::new(
490            store,
491            Arc::new(engine),
492            jwt_config,
493            "test-worker-token".to_string(),
494            event_sender,
495        )
496    }
497
498    async fn get_workflow_json(uri: &str) -> JsonValue {
499        let state = test_state_with_priorities();
500        let auth_header = create_user_auth_header(&state, "testuser", false).await;
501        let app = Router::new()
502            .route("/{name}", get(get_workflow))
503            .with_state(state);
504
505        let req = Request::builder()
506            .uri(uri)
507            .header("authorization", auth_header)
508            .body(Body::empty())
509            .unwrap();
510
511        let resp = app.oneshot(req).await.unwrap();
512        assert_eq!(resp.status(), StatusCode::OK);
513        let body = resp.into_body().collect().await.unwrap().to_bytes();
514        serde_json::from_slice(&body).unwrap()
515    }
516
517    #[tokio::test]
518    async fn get_workflow_returns_handler_priority() {
519        let json_val = get_workflow_json("/prio-workflow").await;
520        assert_eq!(json_val["data"]["priority"], 50);
521    }
522
523    #[tokio::test]
524    async fn get_workflow_priority_defaults_to_zero() {
525        let json_val = get_workflow_json("/my-workflow").await;
526        assert!(json_val["data"].get("priority").is_some());
527        assert_eq!(json_val["data"]["priority"], 0);
528    }
529
530    #[tokio::test]
531    async fn get_workflow_priority_is_clamped() {
532        let json_val = get_workflow_json("/extreme-workflow").await;
533        assert_eq!(json_val["data"]["priority"], 100);
534
535        let json_val = get_workflow_json("/abyss-workflow").await;
536        assert_eq!(json_val["data"]["priority"], -100);
537    }
538
539    #[tokio::test]
540    async fn get_workflow_not_found() {
541        let state = test_state();
542        let auth_header = create_user_auth_header(&state, "testuser", false).await;
543        let app = Router::new()
544            .route("/{name}", get(get_workflow))
545            .with_state(state);
546
547        let req = Request::builder()
548            .uri("/nonexistent")
549            .header("authorization", auth_header)
550            .body(Body::empty())
551            .unwrap();
552
553        let resp = app.oneshot(req).await.unwrap();
554        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
555    }
556}