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 pub version: Option<String>,
36 #[serde(skip_serializing_if = "Option::is_none")]
38 pub schedule: Option<String>,
39}
40
41pub const UNCATEGORIZED_FILTER: &str = "__uncategorized__";
44
45#[cfg_attr(
54 feature = "openapi",
55 utoipa::path(
56 get,
57 path = "/api/v1/workflows",
58 tags = ["workflows"],
59 params(ListWorkflowsQuery),
60 responses(
61 (status = 200, description = "List of workflow summaries", body = [WorkflowSummary]),
62 (status = 401, description = "Unauthorized")
63 ),
64 security(("Bearer" = []))
65 )
66)]
67pub async fn list_workflows(
68 _auth: Authenticated,
69 State(state): State<AppState>,
70 Query(params): Query<ListWorkflowsQuery>,
71) -> Result<impl IntoResponse, ApiError> {
72 let mut summaries: Vec<WorkflowSummary> = state
73 .engine
74 .handler_names()
75 .into_iter()
76 .map(|name| {
77 let info = state.engine.handler_info(name);
78 let category = info.as_ref().and_then(|i| i.category.clone());
79 let version = info.as_ref().and_then(|i| i.version.clone());
80 let schedule = info.and_then(|i| i.schedule.map(|s| s.as_str().to_string()));
81 WorkflowSummary {
82 name: name.to_string(),
83 category,
84 version,
85 schedule,
86 }
87 })
88 .collect();
89
90 if let Some(ref filter) = params.name {
91 let lower = filter.to_lowercase();
92 summaries.retain(|s| s.name.to_lowercase().contains(&lower));
93 }
94
95 if let Some(ref cat_filter) = params.category {
96 if cat_filter == UNCATEGORIZED_FILTER {
97 summaries.retain(|s| s.category.is_none());
98 } else {
99 let needle = cat_filter.to_lowercase();
100 summaries.retain(|s| {
101 s.category
102 .as_deref()
103 .is_some_and(|c| c.to_lowercase().contains(&needle))
104 });
105 }
106 }
107
108 summaries.sort_by(|a, b| {
109 a.category
110 .as_deref()
111 .unwrap_or("")
112 .cmp(b.category.as_deref().unwrap_or(""))
113 .then_with(|| a.name.cmp(&b.name))
114 });
115
116 Ok(ok(summaries))
117}
118
119#[cfg(test)]
120mod tests {
121 use axum::Router;
122 use axum::body::Body;
123 use axum::http::{Request, StatusCode};
124 use axum::routing::get;
125 use http_body_util::BodyExt;
126 use ironflow_core::providers::claude::ClaudeCodeProvider;
127 use ironflow_engine::context::WorkflowContext;
128 use ironflow_engine::engine::Engine;
129 use ironflow_engine::handler::{HandlerFuture, WorkflowHandler};
130 use ironflow_engine::notify::Event;
131 use ironflow_store::memory::InMemoryStore;
132 use serde_json::{Value as JsonValue, from_slice, from_value};
133 use std::sync::Arc;
134 use tokio::sync::broadcast;
135 use tower::ServiceExt;
136
137 use super::*;
138 use crate::routes::test_helpers::create_user_auth_header;
139
140 struct TestWorkflow;
141
142 impl WorkflowHandler for TestWorkflow {
143 fn name(&self) -> &str {
144 "test-workflow"
145 }
146
147 fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
148 Box::pin(async move { Ok(()) })
149 }
150 }
151
152 struct AnotherWorkflow;
153
154 impl WorkflowHandler for AnotherWorkflow {
155 fn name(&self) -> &str {
156 "another-workflow"
157 }
158
159 fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
160 Box::pin(async move { Ok(()) })
161 }
162 }
163
164 struct EtlNightlyWorkflow;
165
166 impl WorkflowHandler for EtlNightlyWorkflow {
167 fn name(&self) -> &str {
168 "etl-nightly"
169 }
170 fn category(&self) -> Option<&str> {
171 Some("data/etl")
172 }
173 fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
174 Box::pin(async move { Ok(()) })
175 }
176 }
177
178 struct ReportsDailyWorkflow;
179
180 impl WorkflowHandler for ReportsDailyWorkflow {
181 fn name(&self) -> &str {
182 "reports-daily"
183 }
184 fn category(&self) -> Option<&str> {
185 Some("data/reports")
186 }
187 fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
188 Box::pin(async move { Ok(()) })
189 }
190 }
191
192 fn base_state(engine: Engine) -> AppState {
193 let store = Arc::new(InMemoryStore::new());
194 Arc::new(InMemoryStore::new());
195 let jwt_config = Arc::new(ironflow_auth::jwt::JwtConfig {
196 secret: "test-secret".to_string(),
197 access_token_ttl_secs: 900,
198 refresh_token_ttl_secs: 604800,
199 cookie_domain: None,
200 cookie_secure: false,
201 });
202 let (event_sender, _) = broadcast::channel::<Event>(1);
203 AppState::new(
204 store,
205 Arc::new(engine),
206 jwt_config,
207 "test-worker-token".to_string(),
208 event_sender,
209 )
210 }
211
212 fn test_state() -> AppState {
213 let store = Arc::new(InMemoryStore::new());
214 let provider = Arc::new(ClaudeCodeProvider::new());
215 let mut engine = Engine::new(store.clone(), provider);
216 engine.register(TestWorkflow).unwrap();
217 engine.register(AnotherWorkflow).unwrap();
218 base_state(engine)
219 }
220
221 fn test_state_with_categories() -> AppState {
222 let store = Arc::new(InMemoryStore::new());
223 let provider = Arc::new(ClaudeCodeProvider::new());
224 let mut engine = Engine::new(store.clone(), provider);
225 engine.register(TestWorkflow).unwrap();
226 engine.register(EtlNightlyWorkflow).unwrap();
227 engine.register(ReportsDailyWorkflow).unwrap();
228 base_state(engine)
229 }
230
231 async fn run_request(state: AppState, uri: &str) -> (StatusCode, Vec<WorkflowSummary>) {
232 let auth_header = create_user_auth_header(&state, "testuser", false).await;
233 let app = Router::new()
234 .route("/", get(list_workflows))
235 .with_state(state);
236
237 let req = Request::builder()
238 .uri(uri)
239 .header("authorization", auth_header)
240 .body(Body::empty())
241 .unwrap();
242
243 let resp = app.oneshot(req).await.unwrap();
244 let status = resp.status();
245 let body = resp.into_body().collect().await.unwrap().to_bytes();
246 let json_val: JsonValue = from_slice(&body).unwrap();
247 let summaries: Vec<WorkflowSummary> = from_value(json_val["data"].clone()).unwrap();
248 (status, summaries)
249 }
250
251 #[tokio::test]
252 async fn list_workflows_empty() {
253 let store = Arc::new(InMemoryStore::new());
254 let provider = Arc::new(ClaudeCodeProvider::new());
255 let engine = Engine::new(store.clone(), provider);
256 let state = base_state(engine);
257
258 let (status, summaries) = run_request(state, "/").await;
259 assert_eq!(status, StatusCode::OK);
260 assert!(summaries.is_empty());
261 }
262
263 #[tokio::test]
264 async fn list_workflows_multiple_returns_summaries() {
265 let state = test_state();
266 let (status, summaries) = run_request(state, "/").await;
267 assert_eq!(status, StatusCode::OK);
268 assert_eq!(summaries.len(), 2);
269 assert!(summaries.iter().any(|s| s.name == "test-workflow"));
270 assert!(summaries.iter().any(|s| s.name == "another-workflow"));
271 assert!(summaries.iter().all(|s| s.category.is_none()));
272 }
273
274 #[tokio::test]
275 async fn list_workflows_filtered_by_name() {
276 let state = test_state();
277 let (_, summaries) = run_request(state, "/?name=test").await;
278 assert_eq!(summaries.len(), 1);
279 assert_eq!(summaries[0].name, "test-workflow");
280 }
281
282 #[tokio::test]
283 async fn list_workflows_filter_name_case_insensitive() {
284 let state = test_state();
285 let (_, summaries) = run_request(state, "/?name=TEST").await;
286 assert_eq!(summaries.len(), 1);
287 assert_eq!(summaries[0].name, "test-workflow");
288 }
289
290 #[tokio::test]
291 async fn list_workflows_filter_no_match() {
292 let state = test_state();
293 let (_, summaries) = run_request(state, "/?name=nonexistent").await;
294 assert!(summaries.is_empty());
295 }
296
297 #[tokio::test]
298 async fn list_workflows_returns_category_when_present() {
299 let state = test_state_with_categories();
300 let (_, summaries) = run_request(state, "/").await;
301 let etl = summaries.iter().find(|s| s.name == "etl-nightly").unwrap();
302 assert_eq!(etl.category.as_deref(), Some("data/etl"));
303 let test = summaries
304 .iter()
305 .find(|s| s.name == "test-workflow")
306 .unwrap();
307 assert!(test.category.is_none());
308 }
309
310 #[tokio::test]
311 async fn list_workflows_filter_by_category_partial() {
312 let state = test_state_with_categories();
313 let (_, summaries) = run_request(state, "/?category=data").await;
314 assert_eq!(summaries.len(), 2);
315 assert!(
316 summaries
317 .iter()
318 .all(|s| { s.category.as_deref().is_some_and(|c| c.contains("data")) })
319 );
320 }
321
322 #[tokio::test]
323 async fn list_workflows_filter_by_category_nested_substring() {
324 let state = test_state_with_categories();
325 let (_, summaries) = run_request(state, "/?category=etl").await;
326 assert_eq!(summaries.len(), 1);
327 assert_eq!(summaries[0].name, "etl-nightly");
328 }
329
330 #[tokio::test]
331 async fn list_workflows_filter_by_category_case_insensitive() {
332 let state = test_state_with_categories();
333 let (_, summaries) = run_request(state, "/?category=DATA").await;
334 assert_eq!(summaries.len(), 2);
335 let (_, summaries) = run_request(test_state_with_categories(), "/?category=Etl").await;
336 assert_eq!(summaries.len(), 1);
337 assert_eq!(summaries[0].name, "etl-nightly");
338 }
339
340 #[tokio::test]
341 async fn list_workflows_filter_by_category_no_match_excludes_uncategorized() {
342 let state = test_state_with_categories();
343 let (_, summaries) = run_request(state, "/?category=nonexistent").await;
344 assert!(summaries.is_empty());
345 }
346
347 #[tokio::test]
348 async fn list_workflows_filter_uncategorized_sentinel() {
349 let state = test_state_with_categories();
350 let (_, summaries) = run_request(state, "/?category=__uncategorized__").await;
351 assert_eq!(summaries.len(), 1);
352 assert_eq!(summaries[0].name, "test-workflow");
353 }
354
355 struct ScheduledWorkflow {
356 schedule: ironflow_engine::prelude::CronSchedule,
357 }
358 impl ScheduledWorkflow {
359 fn new() -> Self {
360 Self {
361 schedule: ironflow_engine::prelude::CronSchedule::new("0 30 9 * * MON-FRI")
362 .unwrap(),
363 }
364 }
365 }
366
367 impl WorkflowHandler for ScheduledWorkflow {
368 fn name(&self) -> &str {
369 "scheduled-workflow"
370 }
371 fn schedule(&self) -> Option<&ironflow_engine::prelude::CronSchedule> {
372 Some(&self.schedule)
373 }
374 fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
375 Box::pin(async move { Ok(()) })
376 }
377 }
378
379 #[tokio::test]
380 async fn list_workflows_returns_schedule_when_present() {
381 let store = Arc::new(InMemoryStore::new());
382 let provider = Arc::new(ClaudeCodeProvider::new());
383 let mut engine = Engine::new(store.clone(), provider);
384 engine.register(TestWorkflow).unwrap();
385 engine.register(ScheduledWorkflow::new()).unwrap();
386 let state = base_state(engine);
387
388 let (_, summaries) = run_request(state, "/").await;
389 let scheduled = summaries
390 .iter()
391 .find(|s| s.name == "scheduled-workflow")
392 .unwrap();
393 assert_eq!(scheduled.schedule.as_deref(), Some("0 30 9 * * MON-FRI"));
394
395 let unscheduled = summaries
396 .iter()
397 .find(|s| s.name == "test-workflow")
398 .unwrap();
399 assert!(unscheduled.schedule.is_none());
400 }
401}