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