1use 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#[cfg_attr(feature = "openapi", derive(utoipa::IntoParams, utoipa::ToSchema))]
14#[derive(Debug, Deserialize)]
15pub struct ListWorkflowsQuery {
16 pub name: Option<String>,
18 pub category: Option<String>,
24}
25
26#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
28#[derive(Debug, Serialize, Deserialize)]
29pub struct WorkflowSummary {
30 pub name: String,
32 pub category: Option<String>,
34}
35
36pub const UNCATEGORIZED_FILTER: &str = "__uncategorized__";
39
40#[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}