Skip to main content

ironflow_api/routes/
list_workflows.rs

1//! `GET /api/v1/workflows` — List registered workflows.
2
3use axum::extract::{Query, State};
4use axum::response::IntoResponse;
5use ironflow_auth::extractor::Authenticated;
6use serde::Deserialize;
7
8use crate::error::ApiError;
9use crate::response::ok;
10use crate::state::AppState;
11
12/// Query parameters for listing workflows.
13#[cfg_attr(feature = "openapi", derive(utoipa::IntoParams, utoipa::ToSchema))]
14#[derive(Debug, Deserialize)]
15pub struct ListWorkflowsQuery {
16    /// Optional case-insensitive partial match on workflow name.
17    pub name: Option<String>,
18}
19
20/// List registered workflow names, optionally filtered by name.
21///
22/// # Query Parameters
23///
24/// - `name` — Filter by workflow name, case-insensitive partial match (optional)
25#[cfg_attr(
26    feature = "openapi",
27    utoipa::path(
28        get,
29        path = "/api/v1/workflows",
30        tags = ["workflows"],
31        params(ListWorkflowsQuery),
32        responses(
33            (status = 200, description = "List of workflow names"),
34            (status = 401, description = "Unauthorized")
35        ),
36        security(("Bearer" = []))
37    )
38)]
39pub async fn list_workflows(
40    _auth: Authenticated,
41    State(state): State<AppState>,
42    Query(params): Query<ListWorkflowsQuery>,
43) -> Result<impl IntoResponse, ApiError> {
44    let mut names: Vec<String> = state
45        .engine
46        .handler_names()
47        .into_iter()
48        .map(|s| s.to_string())
49        .collect();
50
51    if let Some(ref filter) = params.name {
52        let lower = filter.to_lowercase();
53        names.retain(|n| n.to_lowercase().contains(&lower));
54    }
55
56    Ok(ok(names))
57}
58
59#[cfg(test)]
60mod tests {
61    use axum::Router;
62    use axum::body::Body;
63    use axum::http::{Request, StatusCode};
64    use axum::routing::get;
65    use http_body_util::BodyExt;
66    use ironflow_auth::jwt::AccessToken;
67    use ironflow_core::providers::claude::ClaudeCodeProvider;
68    use ironflow_engine::context::WorkflowContext;
69    use ironflow_engine::engine::Engine;
70    use ironflow_engine::handler::{HandlerFuture, WorkflowHandler};
71    use ironflow_engine::notify::Event;
72    use ironflow_store::api_key_store::ApiKeyStore;
73    use ironflow_store::memory::InMemoryStore;
74    use serde_json::{Value as JsonValue, from_slice, from_value};
75    use std::sync::Arc;
76    use tokio::sync::broadcast;
77    use tower::ServiceExt;
78    use uuid::Uuid;
79
80    use super::*;
81
82    fn make_auth_header(state: &AppState) -> String {
83        let user_id = Uuid::now_v7();
84        let token = AccessToken::for_user(user_id, "testuser", false, &state.jwt_config).unwrap();
85        format!("Bearer {}", token.0)
86    }
87
88    struct TestWorkflow;
89
90    impl WorkflowHandler for TestWorkflow {
91        fn name(&self) -> &str {
92            "test-workflow"
93        }
94
95        fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
96            Box::pin(async move { Ok(()) })
97        }
98    }
99
100    struct AnotherWorkflow;
101
102    impl WorkflowHandler for AnotherWorkflow {
103        fn name(&self) -> &str {
104            "another-workflow"
105        }
106
107        fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
108            Box::pin(async move { Ok(()) })
109        }
110    }
111
112    fn test_state() -> AppState {
113        let store = Arc::new(InMemoryStore::new());
114        let user_store: Arc<dyn ironflow_store::user_store::UserStore> =
115            Arc::new(InMemoryStore::new());
116        let api_key_store: Arc<dyn ApiKeyStore> = Arc::new(InMemoryStore::new());
117        let provider = Arc::new(ClaudeCodeProvider::new());
118        let mut engine = Engine::new(store.clone(), provider);
119        engine.register(TestWorkflow).unwrap();
120        engine.register(AnotherWorkflow).unwrap();
121        let jwt_config = Arc::new(ironflow_auth::jwt::JwtConfig {
122            secret: "test-secret".to_string(),
123            access_token_ttl_secs: 900,
124            refresh_token_ttl_secs: 604800,
125            cookie_domain: None,
126            cookie_secure: false,
127        });
128        let (event_sender, _) = broadcast::channel::<Event>(1);
129        AppState::new(
130            store,
131            user_store,
132            api_key_store,
133            Arc::new(engine),
134            jwt_config,
135            "test-worker-token".to_string(),
136            event_sender,
137        )
138    }
139
140    #[tokio::test]
141    async fn list_workflows_empty() {
142        let store = Arc::new(InMemoryStore::new());
143        let user_store: Arc<dyn ironflow_store::user_store::UserStore> =
144            Arc::new(InMemoryStore::new());
145        let api_key_store: Arc<dyn ApiKeyStore> = Arc::new(InMemoryStore::new());
146        let provider = Arc::new(ClaudeCodeProvider::new());
147        let engine = Arc::new(Engine::new(store.clone(), provider));
148        let jwt_config = Arc::new(ironflow_auth::jwt::JwtConfig {
149            secret: "test-secret".to_string(),
150            access_token_ttl_secs: 900,
151            refresh_token_ttl_secs: 604800,
152            cookie_domain: None,
153            cookie_secure: false,
154        });
155        let (event_sender, _) = broadcast::channel::<Event>(1);
156        let state = AppState::new(
157            store,
158            user_store,
159            api_key_store,
160            engine,
161            jwt_config,
162            "test-worker-token".to_string(),
163            event_sender,
164        );
165        let auth_header = make_auth_header(&state);
166
167        let app = Router::new()
168            .route("/", get(list_workflows))
169            .with_state(state);
170
171        let req = Request::builder()
172            .uri("/")
173            .header("authorization", auth_header)
174            .body(Body::empty())
175            .unwrap();
176
177        let resp = app.oneshot(req).await.unwrap();
178        assert_eq!(resp.status(), StatusCode::OK);
179
180        let body = resp.into_body().collect().await.unwrap().to_bytes();
181        let json_val: JsonValue = from_slice(&body).unwrap();
182        assert_eq!(json_val["data"].as_array().unwrap().len(), 0);
183    }
184
185    #[tokio::test]
186    async fn list_workflows_multiple() {
187        let state = test_state();
188        let auth_header = make_auth_header(&state);
189        let app = Router::new()
190            .route("/", get(list_workflows))
191            .with_state(state);
192
193        let req = Request::builder()
194            .uri("/")
195            .header("authorization", auth_header)
196            .body(Body::empty())
197            .unwrap();
198
199        let resp = app.oneshot(req).await.unwrap();
200        assert_eq!(resp.status(), StatusCode::OK);
201
202        let body = resp.into_body().collect().await.unwrap().to_bytes();
203        let json_val: JsonValue = from_slice(&body).unwrap();
204        let workflows: Vec<String> = from_value(json_val["data"].clone()).unwrap();
205        assert_eq!(workflows.len(), 2);
206        assert!(workflows.contains(&"test-workflow".to_string()));
207        assert!(workflows.contains(&"another-workflow".to_string()));
208    }
209
210    #[tokio::test]
211    async fn list_workflows_filtered_by_name() {
212        let state = test_state();
213        let auth_header = make_auth_header(&state);
214        let app = Router::new()
215            .route("/", get(list_workflows))
216            .with_state(state);
217
218        let req = Request::builder()
219            .uri("/?name=test")
220            .header("authorization", &auth_header)
221            .body(Body::empty())
222            .unwrap();
223
224        let resp = app.oneshot(req).await.unwrap();
225        assert_eq!(resp.status(), StatusCode::OK);
226
227        let body = resp.into_body().collect().await.unwrap().to_bytes();
228        let json_val: JsonValue = from_slice(&body).unwrap();
229        let workflows: Vec<String> = from_value(json_val["data"].clone()).unwrap();
230        assert_eq!(workflows.len(), 1);
231        assert_eq!(workflows[0], "test-workflow");
232    }
233
234    #[tokio::test]
235    async fn list_workflows_filter_case_insensitive() {
236        let state = test_state();
237        let auth_header = make_auth_header(&state);
238        let app = Router::new()
239            .route("/", get(list_workflows))
240            .with_state(state);
241
242        let req = Request::builder()
243            .uri("/?name=TEST")
244            .header("authorization", &auth_header)
245            .body(Body::empty())
246            .unwrap();
247
248        let resp = app.oneshot(req).await.unwrap();
249        let body = resp.into_body().collect().await.unwrap().to_bytes();
250        let json_val: JsonValue = from_slice(&body).unwrap();
251        let workflows: Vec<String> = from_value(json_val["data"].clone()).unwrap();
252        assert_eq!(workflows.len(), 1);
253        assert_eq!(workflows[0], "test-workflow");
254    }
255
256    #[tokio::test]
257    async fn list_workflows_filter_no_match() {
258        let state = test_state();
259        let auth_header = make_auth_header(&state);
260        let app = Router::new()
261            .route("/", get(list_workflows))
262            .with_state(state);
263
264        let req = Request::builder()
265            .uri("/?name=nonexistent")
266            .header("authorization", &auth_header)
267            .body(Body::empty())
268            .unwrap();
269
270        let resp = app.oneshot(req).await.unwrap();
271        let body = resp.into_body().collect().await.unwrap().to_bytes();
272        let json_val: JsonValue = from_slice(&body).unwrap();
273        let workflows: Vec<String> = from_value(json_val["data"].clone()).unwrap();
274        assert!(workflows.is_empty());
275    }
276}