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, Serialize};
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    /// Optional case-insensitive partial match on the category path
19    /// (e.g. `etl` matches `Data/ETL` and `data/etl/nightly`).
20    ///
21    /// Pass `__uncategorized__` to list only workflows without any
22    /// category.
23    pub category: Option<String>,
24}
25
26/// Summary entry returned by `GET /api/v1/workflows`.
27#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
28#[derive(Debug, Serialize, Deserialize)]
29pub struct WorkflowSummary {
30    /// Workflow name (unique identifier).
31    pub name: String,
32    /// Optional `/`-separated category path.
33    pub category: Option<String>,
34}
35
36/// Sentinel value for the `category` query parameter that selects only
37/// uncategorized workflows.
38pub const UNCATEGORIZED_FILTER: &str = "__uncategorized__";
39
40/// List registered workflows, optionally filtered by name and category.
41///
42/// # Query Parameters
43///
44/// - `name` — Case-insensitive partial match on workflow name (optional)
45/// - `category` — Case-insensitive partial match on category path, or
46///   [`UNCATEGORIZED_FILTER`] to filter only uncategorized workflows
47///   (optional)
48#[cfg_attr(
49    feature = "openapi",
50    utoipa::path(
51        get,
52        path = "/api/v1/workflows",
53        tags = ["workflows"],
54        params(ListWorkflowsQuery),
55        responses(
56            (status = 200, description = "List of workflow summaries", body = [WorkflowSummary]),
57            (status = 401, description = "Unauthorized")
58        ),
59        security(("Bearer" = []))
60    )
61)]
62pub async fn list_workflows(
63    _auth: Authenticated,
64    State(state): State<AppState>,
65    Query(params): Query<ListWorkflowsQuery>,
66) -> Result<impl IntoResponse, ApiError> {
67    let mut summaries: Vec<WorkflowSummary> = state
68        .engine
69        .handler_names()
70        .into_iter()
71        .map(|name| {
72            let category = state
73                .engine
74                .handler_info(name)
75                .and_then(|info| info.category);
76            WorkflowSummary {
77                name: name.to_string(),
78                category,
79            }
80        })
81        .collect();
82
83    if let Some(ref filter) = params.name {
84        let lower = filter.to_lowercase();
85        summaries.retain(|s| s.name.to_lowercase().contains(&lower));
86    }
87
88    if let Some(ref cat_filter) = params.category {
89        if cat_filter == UNCATEGORIZED_FILTER {
90            summaries.retain(|s| s.category.is_none());
91        } else {
92            let needle = cat_filter.to_lowercase();
93            summaries.retain(|s| {
94                s.category
95                    .as_deref()
96                    .is_some_and(|c| c.to_lowercase().contains(&needle))
97            });
98        }
99    }
100
101    summaries.sort_by(|a, b| {
102        a.category
103            .as_deref()
104            .unwrap_or("")
105            .cmp(b.category.as_deref().unwrap_or(""))
106            .then_with(|| a.name.cmp(&b.name))
107    });
108
109    Ok(ok(summaries))
110}
111
112#[cfg(test)]
113mod tests {
114    use axum::Router;
115    use axum::body::Body;
116    use axum::http::{Request, StatusCode};
117    use axum::routing::get;
118    use http_body_util::BodyExt;
119    use ironflow_auth::jwt::AccessToken;
120    use ironflow_core::providers::claude::ClaudeCodeProvider;
121    use ironflow_engine::context::WorkflowContext;
122    use ironflow_engine::engine::Engine;
123    use ironflow_engine::handler::{HandlerFuture, WorkflowHandler};
124    use ironflow_engine::notify::Event;
125    use ironflow_store::api_key_store::ApiKeyStore;
126    use ironflow_store::memory::InMemoryStore;
127    use serde_json::{Value as JsonValue, from_slice, from_value};
128    use std::sync::Arc;
129    use tokio::sync::broadcast;
130    use tower::ServiceExt;
131    use uuid::Uuid;
132
133    use super::*;
134
135    fn make_auth_header(state: &AppState) -> String {
136        let user_id = Uuid::now_v7();
137        let token = AccessToken::for_user(user_id, "testuser", false, &state.jwt_config).unwrap();
138        format!("Bearer {}", token.0)
139    }
140
141    struct TestWorkflow;
142
143    impl WorkflowHandler for TestWorkflow {
144        fn name(&self) -> &str {
145            "test-workflow"
146        }
147
148        fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
149            Box::pin(async move { Ok(()) })
150        }
151    }
152
153    struct AnotherWorkflow;
154
155    impl WorkflowHandler for AnotherWorkflow {
156        fn name(&self) -> &str {
157            "another-workflow"
158        }
159
160        fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
161            Box::pin(async move { Ok(()) })
162        }
163    }
164
165    struct EtlNightlyWorkflow;
166
167    impl WorkflowHandler for EtlNightlyWorkflow {
168        fn name(&self) -> &str {
169            "etl-nightly"
170        }
171        fn category(&self) -> Option<&str> {
172            Some("data/etl")
173        }
174        fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
175            Box::pin(async move { Ok(()) })
176        }
177    }
178
179    struct ReportsDailyWorkflow;
180
181    impl WorkflowHandler for ReportsDailyWorkflow {
182        fn name(&self) -> &str {
183            "reports-daily"
184        }
185        fn category(&self) -> Option<&str> {
186            Some("data/reports")
187        }
188        fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
189            Box::pin(async move { Ok(()) })
190        }
191    }
192
193    fn base_state(engine: Engine) -> AppState {
194        let store = Arc::new(InMemoryStore::new());
195        let user_store: Arc<dyn ironflow_store::user_store::UserStore> =
196            Arc::new(InMemoryStore::new());
197        let api_key_store: Arc<dyn ApiKeyStore> = Arc::new(InMemoryStore::new());
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            user_store,
209            api_key_store,
210            Arc::new(engine),
211            jwt_config,
212            "test-worker-token".to_string(),
213            event_sender,
214        )
215    }
216
217    fn test_state() -> AppState {
218        let store = Arc::new(InMemoryStore::new());
219        let provider = Arc::new(ClaudeCodeProvider::new());
220        let mut engine = Engine::new(store.clone(), provider);
221        engine.register(TestWorkflow).unwrap();
222        engine.register(AnotherWorkflow).unwrap();
223        base_state(engine)
224    }
225
226    fn test_state_with_categories() -> AppState {
227        let store = Arc::new(InMemoryStore::new());
228        let provider = Arc::new(ClaudeCodeProvider::new());
229        let mut engine = Engine::new(store.clone(), provider);
230        engine.register(TestWorkflow).unwrap();
231        engine.register(EtlNightlyWorkflow).unwrap();
232        engine.register(ReportsDailyWorkflow).unwrap();
233        base_state(engine)
234    }
235
236    async fn run_request(state: AppState, uri: &str) -> (StatusCode, Vec<WorkflowSummary>) {
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(uri)
244            .header("authorization", auth_header)
245            .body(Body::empty())
246            .unwrap();
247
248        let resp = app.oneshot(req).await.unwrap();
249        let status = resp.status();
250        let body = resp.into_body().collect().await.unwrap().to_bytes();
251        let json_val: JsonValue = from_slice(&body).unwrap();
252        let summaries: Vec<WorkflowSummary> = from_value(json_val["data"].clone()).unwrap();
253        (status, summaries)
254    }
255
256    #[tokio::test]
257    async fn list_workflows_empty() {
258        let store = Arc::new(InMemoryStore::new());
259        let provider = Arc::new(ClaudeCodeProvider::new());
260        let engine = Engine::new(store.clone(), provider);
261        let state = base_state(engine);
262
263        let (status, summaries) = run_request(state, "/").await;
264        assert_eq!(status, StatusCode::OK);
265        assert!(summaries.is_empty());
266    }
267
268    #[tokio::test]
269    async fn list_workflows_multiple_returns_summaries() {
270        let state = test_state();
271        let (status, summaries) = run_request(state, "/").await;
272        assert_eq!(status, StatusCode::OK);
273        assert_eq!(summaries.len(), 2);
274        assert!(summaries.iter().any(|s| s.name == "test-workflow"));
275        assert!(summaries.iter().any(|s| s.name == "another-workflow"));
276        assert!(summaries.iter().all(|s| s.category.is_none()));
277    }
278
279    #[tokio::test]
280    async fn list_workflows_filtered_by_name() {
281        let state = test_state();
282        let (_, summaries) = run_request(state, "/?name=test").await;
283        assert_eq!(summaries.len(), 1);
284        assert_eq!(summaries[0].name, "test-workflow");
285    }
286
287    #[tokio::test]
288    async fn list_workflows_filter_name_case_insensitive() {
289        let state = test_state();
290        let (_, summaries) = run_request(state, "/?name=TEST").await;
291        assert_eq!(summaries.len(), 1);
292        assert_eq!(summaries[0].name, "test-workflow");
293    }
294
295    #[tokio::test]
296    async fn list_workflows_filter_no_match() {
297        let state = test_state();
298        let (_, summaries) = run_request(state, "/?name=nonexistent").await;
299        assert!(summaries.is_empty());
300    }
301
302    #[tokio::test]
303    async fn list_workflows_returns_category_when_present() {
304        let state = test_state_with_categories();
305        let (_, summaries) = run_request(state, "/").await;
306        let etl = summaries.iter().find(|s| s.name == "etl-nightly").unwrap();
307        assert_eq!(etl.category.as_deref(), Some("data/etl"));
308        let test = summaries
309            .iter()
310            .find(|s| s.name == "test-workflow")
311            .unwrap();
312        assert!(test.category.is_none());
313    }
314
315    #[tokio::test]
316    async fn list_workflows_filter_by_category_partial() {
317        let state = test_state_with_categories();
318        let (_, summaries) = run_request(state, "/?category=data").await;
319        assert_eq!(summaries.len(), 2);
320        assert!(
321            summaries
322                .iter()
323                .all(|s| { s.category.as_deref().is_some_and(|c| c.contains("data")) })
324        );
325    }
326
327    #[tokio::test]
328    async fn list_workflows_filter_by_category_nested_substring() {
329        let state = test_state_with_categories();
330        let (_, summaries) = run_request(state, "/?category=etl").await;
331        assert_eq!(summaries.len(), 1);
332        assert_eq!(summaries[0].name, "etl-nightly");
333    }
334
335    #[tokio::test]
336    async fn list_workflows_filter_by_category_case_insensitive() {
337        let state = test_state_with_categories();
338        let (_, summaries) = run_request(state, "/?category=DATA").await;
339        assert_eq!(summaries.len(), 2);
340        let (_, summaries) = run_request(test_state_with_categories(), "/?category=Etl").await;
341        assert_eq!(summaries.len(), 1);
342        assert_eq!(summaries[0].name, "etl-nightly");
343    }
344
345    #[tokio::test]
346    async fn list_workflows_filter_by_category_no_match_excludes_uncategorized() {
347        let state = test_state_with_categories();
348        let (_, summaries) = run_request(state, "/?category=nonexistent").await;
349        assert!(summaries.is_empty());
350    }
351
352    #[tokio::test]
353    async fn list_workflows_filter_uncategorized_sentinel() {
354        let state = test_state_with_categories();
355        let (_, summaries) = run_request(state, "/?category=__uncategorized__").await;
356        assert_eq!(summaries.len(), 1);
357        assert_eq!(summaries[0].name, "test-workflow");
358    }
359}