Skip to main content

ironflow_api/routes/
get_workflow.rs

1//! `GET /api/v1/workflows/:name` — Get workflow details.
2
3use std::collections::HashSet;
4
5use axum::extract::{Path, State};
6use axum::response::IntoResponse;
7use ironflow_auth::extractor::Authenticated;
8use serde::Serialize;
9
10use crate::error::ApiError;
11use crate::response::ok;
12use crate::state::AppState;
13
14/// Sub-workflow detail included in the workflow response.
15#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
16#[derive(Debug, Serialize)]
17pub struct SubWorkflowDetail {
18    /// Sub-workflow name.
19    pub name: String,
20    /// Human-readable description.
21    pub description: String,
22    /// Optional Rust source code of the handler.
23    pub source_code: Option<String>,
24}
25
26/// Workflow detail response.
27#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
28#[derive(Debug, Serialize)]
29pub struct WorkflowDetailResponse {
30    /// Workflow name.
31    pub name: String,
32    /// Human-readable description.
33    pub description: String,
34    /// Optional Rust source code of the handler.
35    pub source_code: Option<String>,
36    /// Sub-workflows invoked by this handler (recursive, depth-limited).
37    pub sub_workflows: Vec<SubWorkflowDetail>,
38}
39
40/// Get details about a registered workflow.
41///
42/// # Errors
43///
44/// - 404 if the workflow is not registered
45#[cfg_attr(
46    feature = "openapi",
47    utoipa::path(
48        get,
49        path = "/api/v1/workflows/{name}",
50        tags = ["workflows"],
51        params(("name" = String, Path, description = "Workflow name")),
52        responses(
53            (status = 200, description = "Workflow details", body = WorkflowDetailResponse),
54            (status = 401, description = "Unauthorized"),
55            (status = 404, description = "Workflow not found")
56        ),
57        security(("Bearer" = []))
58    )
59)]
60pub async fn get_workflow(
61    _auth: Authenticated,
62    State(state): State<AppState>,
63    Path(name): Path<String>,
64) -> Result<impl IntoResponse, ApiError> {
65    let info = state
66        .engine
67        .handler_info(&name)
68        .ok_or_else(|| ApiError::WorkflowNotFound(name.clone()))?;
69
70    let mut sub_workflows = Vec::new();
71    let mut visited = HashSet::new();
72    visited.insert(name.clone());
73    collect_sub_workflows(
74        &state,
75        &info.sub_workflows,
76        &mut sub_workflows,
77        &mut visited,
78        5,
79    );
80
81    Ok(ok(WorkflowDetailResponse {
82        name,
83        description: info.description,
84        source_code: info.source_code,
85        sub_workflows,
86    }))
87}
88
89fn collect_sub_workflows(
90    state: &AppState,
91    names: &[String],
92    result: &mut Vec<SubWorkflowDetail>,
93    visited: &mut HashSet<String>,
94    depth: usize,
95) {
96    if depth == 0 {
97        return;
98    }
99    for sub_name in names {
100        if !visited.insert(sub_name.clone()) {
101            continue;
102        }
103        if let Some(sub_info) = state.engine.handler_info(sub_name) {
104            collect_sub_workflows(state, &sub_info.sub_workflows, result, visited, depth - 1);
105            result.push(SubWorkflowDetail {
106                name: sub_name.clone(),
107                description: sub_info.description,
108                source_code: sub_info.source_code,
109            });
110        }
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use axum::Router;
117    use axum::body::Body;
118    use axum::http::{Request, StatusCode};
119    use axum::routing::get;
120    use http_body_util::BodyExt;
121    use ironflow_auth::jwt::AccessToken;
122    use ironflow_core::providers::claude::ClaudeCodeProvider;
123    use ironflow_engine::context::WorkflowContext;
124    use ironflow_engine::engine::Engine;
125    use ironflow_engine::handler::{HandlerFuture, WorkflowHandler};
126    use ironflow_engine::notify::Event;
127    use ironflow_store::api_key_store::ApiKeyStore;
128    use ironflow_store::memory::InMemoryStore;
129    use serde_json::Value as JsonValue;
130    use std::sync::Arc;
131    use tokio::sync::broadcast;
132    use tower::ServiceExt;
133    use uuid::Uuid;
134
135    use super::*;
136
137    struct DescribedWorkflow;
138    impl WorkflowHandler for DescribedWorkflow {
139        fn name(&self) -> &str {
140            "my-workflow"
141        }
142        fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
143            Box::pin(async move { Ok(()) })
144        }
145    }
146
147    fn test_state() -> AppState {
148        let store = Arc::new(InMemoryStore::new());
149        let user_store: Arc<dyn ironflow_store::user_store::UserStore> =
150            Arc::new(InMemoryStore::new());
151        let api_key_store: Arc<dyn ApiKeyStore> = Arc::new(InMemoryStore::new());
152        let provider = Arc::new(ClaudeCodeProvider::new());
153        let mut engine = Engine::new(store.clone(), provider);
154        engine.register(DescribedWorkflow).unwrap();
155        let jwt_config = Arc::new(ironflow_auth::jwt::JwtConfig {
156            secret: "test-secret".to_string(),
157            access_token_ttl_secs: 900,
158            refresh_token_ttl_secs: 604800,
159            cookie_domain: None,
160            cookie_secure: false,
161        });
162        let (event_sender, _) = broadcast::channel::<Event>(1);
163        AppState::new(
164            store,
165            user_store,
166            api_key_store,
167            Arc::new(engine),
168            jwt_config,
169            "test-worker-token".to_string(),
170            event_sender,
171        )
172    }
173
174    fn make_auth_header(state: &AppState) -> String {
175        let user_id = Uuid::now_v7();
176        let token = AccessToken::for_user(user_id, "testuser", false, &state.jwt_config).unwrap();
177        format!("Bearer {}", token.0)
178    }
179
180    #[tokio::test]
181    async fn get_workflow_found() {
182        let state = test_state();
183        let auth_header = make_auth_header(&state);
184        let app = Router::new()
185            .route("/{name}", get(get_workflow))
186            .with_state(state);
187
188        let req = Request::builder()
189            .uri("/my-workflow")
190            .header("authorization", auth_header)
191            .body(Body::empty())
192            .unwrap();
193
194        let resp = app.oneshot(req).await.unwrap();
195        assert_eq!(resp.status(), StatusCode::OK);
196
197        let body = resp.into_body().collect().await.unwrap().to_bytes();
198        let json_val: JsonValue = serde_json::from_slice(&body).unwrap();
199        assert_eq!(json_val["data"]["name"], "my-workflow");
200    }
201
202    #[tokio::test]
203    async fn get_workflow_not_found() {
204        let state = test_state();
205        let auth_header = make_auth_header(&state);
206        let app = Router::new()
207            .route("/{name}", get(get_workflow))
208            .with_state(state);
209
210        let req = Request::builder()
211            .uri("/nonexistent")
212            .header("authorization", auth_header)
213            .body(Body::empty())
214            .unwrap();
215
216        let resp = app.oneshot(req).await.unwrap();
217        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
218    }
219}