1use std::sync::Arc;
2
3use axum::body::Bytes;
4use axum::extract::{Path, Query, State};
5use axum::routing::{get, post};
6use axum::{Json, Router};
7use serde::{Deserialize, Serialize};
8use utoipa::ToSchema;
9
10use crate::ctx::WorkflowCtx;
11use crate::store::WorkflowStore;
12use crate::types::WorkflowStatus;
13
14pub fn router<S: WorkflowStore + 'static>() -> Router<Arc<WorkflowCtx<S>>> {
15 Router::new()
16 .route("/workflows", post(start_workflow).get(list_workflows))
17 .route("/workflows/{id}", get(describe_workflow))
18 .route("/workflows/{id}/events", get(get_events))
19 .route("/workflows/{id}/signal/{name}", post(send_signal))
20 .route("/workflows/{id}/cancel", post(cancel_workflow))
21 .route("/workflows/{id}/terminate", post(terminate_workflow))
22 .route("/workflows/{id}/children", get(list_children))
23 .route("/workflows/{id}/continue-as-new", post(continue_as_new))
24 .route("/workflows/{id}/state", get(get_workflow_state))
25 .route(
26 "/workflows/{id}/state/{name}",
27 get(get_workflow_state_by_name),
28 )
29}
30
31#[derive(Deserialize, ToSchema)]
32pub struct StartWorkflowRequest {
33 pub namespace: Option<String>,
35 pub workflow_type: String,
37 pub workflow_id: String,
39 pub input: Option<serde_json::Value>,
41 #[serde(default = "default_queue")]
43 pub task_queue: String,
44 pub search_attributes: Option<serde_json::Value>,
48}
49
50fn default_queue() -> String {
51 "main".to_string()
52}
53
54#[derive(Serialize, ToSchema)]
55pub struct WorkflowResponse {
56 pub workflow_id: String,
57 pub run_id: String,
58 pub status: String,
59}
60
61#[utoipa::path(
62 post, path = "/api/v1/engine/workflow/workflows",
63 tag = "workflows",
64 request_body = StartWorkflowRequest,
65 responses(
66 (status = 201, description = "Workflow started", body = WorkflowResponse),
67 (status = 500, description = "Internal error"),
68 ),
69)]
70pub async fn start_workflow<S: WorkflowStore>(
71 State(state): State<Arc<WorkflowCtx<S>>>,
72 Json(req): Json<StartWorkflowRequest>,
73) -> Result<(axum::http::StatusCode, Json<WorkflowResponse>), AppError> {
74 let input = req.input.map(|v| v.to_string());
75 let namespace = req.namespace.as_deref().unwrap_or("main");
76 let search_attributes = req.search_attributes.map(|v| v.to_string());
77 let wf = state
78 .start_workflow(
79 namespace,
80 &req.workflow_type,
81 &req.workflow_id,
82 input.as_deref(),
83 &req.task_queue,
84 search_attributes.as_deref(),
85 )
86 .await?;
87
88 Ok((
89 axum::http::StatusCode::CREATED,
90 Json(WorkflowResponse {
91 workflow_id: wf.id,
92 run_id: wf.run_id,
93 status: wf.status,
94 }),
95 ))
96}
97
98#[derive(Deserialize)]
99pub struct ListQuery {
100 #[serde(default = "default_namespace")]
101 pub namespace: String,
102 pub status: Option<String>,
103 #[serde(rename = "type")]
104 pub workflow_type: Option<String>,
105 pub search_attrs: Option<String>,
109 #[serde(default = "default_limit")]
110 pub limit: i64,
111 #[serde(default)]
112 pub offset: i64,
113}
114
115fn default_namespace() -> String {
116 "main".to_string()
117}
118
119fn default_limit() -> i64 {
120 50
121}
122
123#[utoipa::path(
124 get, path = "/api/v1/engine/workflow/workflows",
125 tag = "workflows",
126 params(
127 ("status" = Option<String>, Query, description = "Filter by status"),
128 ("type" = Option<String>, Query, description = "Filter by workflow type"),
129 ("limit" = Option<i64>, Query, description = "Max results (default 50)"),
130 ("offset" = Option<i64>, Query, description = "Pagination offset"),
131 ),
132 responses(
133 (status = 200, description = "List of workflows", body = Vec<WorkflowRecord>),
134 ),
135)]
136pub async fn list_workflows<S: WorkflowStore>(
137 State(state): State<Arc<WorkflowCtx<S>>>,
138 Query(q): Query<ListQuery>,
139) -> Result<Json<Vec<serde_json::Value>>, AppError> {
140 let status = q
141 .status
142 .as_deref()
143 .and_then(|s| s.parse::<WorkflowStatus>().ok());
144
145 let workflows = state
146 .list_workflows(
147 &q.namespace,
148 status,
149 q.workflow_type.as_deref(),
150 q.search_attrs.as_deref(),
151 q.limit,
152 q.offset,
153 )
154 .await?;
155
156 let json: Vec<serde_json::Value> = workflows
157 .into_iter()
158 .map(|w| serde_json::to_value(w).unwrap_or_default())
159 .collect();
160
161 Ok(Json(json))
162}
163
164#[utoipa::path(
165 get, path = "/api/v1/engine/workflow/workflows/{id}",
166 tag = "workflows",
167 params(("id" = String, Path, description = "Workflow ID")),
168 responses(
169 (status = 200, description = "Workflow details", body = WorkflowRecord),
170 (status = 404, description = "Workflow not found"),
171 ),
172)]
173pub async fn describe_workflow<S: WorkflowStore>(
174 State(state): State<Arc<WorkflowCtx<S>>>,
175 Path(id): Path<String>,
176) -> Result<Json<serde_json::Value>, AppError> {
177 let wf = state
178 .get_workflow(&id)
179 .await?
180 .ok_or(AppError::NotFound(format!("workflow {id}")))?;
181
182 Ok(Json(serde_json::to_value(wf)?))
183}
184
185#[utoipa::path(
186 get, path = "/api/v1/engine/workflow/workflows/{id}/events",
187 tag = "workflows",
188 params(("id" = String, Path, description = "Workflow ID")),
189 responses(
190 (status = 200, description = "Event history", body = Vec<WorkflowEvent>),
191 ),
192)]
193pub async fn get_events<S: WorkflowStore>(
194 State(state): State<Arc<WorkflowCtx<S>>>,
195 Path(id): Path<String>,
196) -> Result<Json<Vec<serde_json::Value>>, AppError> {
197 let events = state.get_events(&id).await?;
198 let json: Vec<serde_json::Value> = events
199 .into_iter()
200 .map(|e| serde_json::to_value(e).unwrap_or_default())
201 .collect();
202 Ok(Json(json))
203}
204
205#[derive(Deserialize, ToSchema)]
206pub struct SignalBody {
207 pub payload: Option<serde_json::Value>,
208}
209
210#[utoipa::path(
211 post, path = "/api/v1/engine/workflow/workflows/{id}/signal/{name}",
212 tag = "workflows",
213 params(
214 ("id" = String, Path, description = "Workflow ID"),
215 ("name" = String, Path, description = "Signal name"),
216 ),
217 responses(
218 (status = 200, description = "Signal sent"),
219 ),
220)]
221pub async fn send_signal<S: WorkflowStore>(
222 State(state): State<Arc<WorkflowCtx<S>>>,
223 Path((id, name)): Path<(String, String)>,
224 Json(body): Json<Option<SignalBody>>,
225) -> Result<axum::http::StatusCode, AppError> {
226 let payload = body.and_then(|b| b.payload).map(|v| v.to_string());
227 state.send_signal(&id, &name, payload.as_deref()).await?;
228 Ok(axum::http::StatusCode::OK)
229}
230
231#[derive(Deserialize, ToSchema, Default)]
232pub struct CancelBody {
233 pub reason: Option<String>,
237}
238
239#[utoipa::path(
240 post, path = "/api/v1/engine/workflow/workflows/{id}/cancel",
241 tag = "workflows",
242 params(("id" = String, Path, description = "Workflow ID")),
243 request_body = CancelBody,
244 responses(
245 (status = 200, description = "Workflow cancelled"),
246 (status = 404, description = "Workflow not found or already terminal"),
247 ),
248)]
249pub async fn cancel_workflow<S: WorkflowStore>(
250 State(state): State<Arc<WorkflowCtx<S>>>,
251 Path(id): Path<String>,
252 body: Bytes,
253) -> Result<axum::http::StatusCode, AppError> {
254 let reason = if body.is_empty() {
259 None
260 } else {
261 serde_json::from_slice::<CancelBody>(&body)
262 .ok()
263 .and_then(|b| b.reason)
264 };
265 let cancelled = state.cancel_workflow(&id, reason.as_deref()).await?;
266 if cancelled {
267 Ok(axum::http::StatusCode::OK)
268 } else {
269 Err(AppError::NotFound(format!(
270 "workflow {id} not found or already terminal"
271 )))
272 }
273}
274
275#[derive(Deserialize, ToSchema)]
276pub struct TerminateBody {
277 pub reason: Option<String>,
278}
279
280#[utoipa::path(
281 post, path = "/api/v1/engine/workflow/workflows/{id}/terminate",
282 tag = "workflows",
283 params(("id" = String, Path, description = "Workflow ID")),
284 responses(
285 (status = 200, description = "Workflow terminated"),
286 (status = 404, description = "Workflow not found or already terminal"),
287 ),
288)]
289pub async fn terminate_workflow<S: WorkflowStore>(
290 State(state): State<Arc<WorkflowCtx<S>>>,
291 Path(id): Path<String>,
292 Json(body): Json<Option<TerminateBody>>,
293) -> Result<axum::http::StatusCode, AppError> {
294 let reason = body.and_then(|b| b.reason);
295 let terminated = state.terminate_workflow(&id, reason.as_deref()).await?;
296 if terminated {
297 Ok(axum::http::StatusCode::OK)
298 } else {
299 Err(AppError::NotFound(format!(
300 "workflow {id} not found or already terminal"
301 )))
302 }
303}
304
305#[utoipa::path(
306 get, path = "/api/v1/engine/workflow/workflows/{id}/children",
307 tag = "workflows",
308 params(("id" = String, Path, description = "Parent workflow ID")),
309 responses(
310 (status = 200, description = "Child workflows", body = Vec<WorkflowRecord>),
311 ),
312)]
313pub async fn list_children<S: WorkflowStore>(
314 State(state): State<Arc<WorkflowCtx<S>>>,
315 Path(id): Path<String>,
316) -> Result<Json<Vec<serde_json::Value>>, AppError> {
317 let children = state.list_child_workflows(&id).await?;
318 let json: Vec<serde_json::Value> = children
319 .into_iter()
320 .map(|w| serde_json::to_value(w).unwrap_or_default())
321 .collect();
322 Ok(Json(json))
323}
324
325#[derive(Deserialize, ToSchema)]
326pub struct ContinueAsNewBody {
327 pub input: Option<serde_json::Value>,
329 pub workflow_id: Option<String>,
334}
335
336#[utoipa::path(
337 post, path = "/api/v1/engine/workflow/workflows/{id}/continue-as-new",
338 tag = "workflows",
339 params(("id" = String, Path, description = "Workflow ID to continue")),
340 request_body = ContinueAsNewBody,
341 responses(
342 (status = 201, description = "New workflow run started", body = WorkflowResponse),
343 ),
344)]
345pub async fn continue_as_new<S: WorkflowStore>(
346 State(state): State<Arc<WorkflowCtx<S>>>,
347 Path(id): Path<String>,
348 Json(body): Json<ContinueAsNewBody>,
349) -> Result<(axum::http::StatusCode, Json<WorkflowResponse>), AppError> {
350 let input = body.input.map(|v| v.to_string());
351 let new_id = body.workflow_id.as_deref().filter(|s| !s.trim().is_empty());
352 let wf = state.continue_as_new(&id, input.as_deref(), new_id).await?;
353
354 Ok((
355 axum::http::StatusCode::CREATED,
356 Json(WorkflowResponse {
357 workflow_id: wf.id,
358 run_id: wf.run_id,
359 status: wf.status,
360 }),
361 ))
362}
363
364#[utoipa::path(
374 get, path = "/api/v1/engine/workflow/workflows/{id}/state",
375 tag = "workflows",
376 params(("id" = String, Path, description = "Workflow ID")),
377 responses(
378 (status = 200, description = "Latest state snapshot"),
379 (status = 404, description = "No snapshot recorded for this workflow"),
380 ),
381)]
382pub async fn get_workflow_state<S: WorkflowStore>(
383 State(state): State<Arc<WorkflowCtx<S>>>,
384 Path(id): Path<String>,
385) -> Result<Json<serde_json::Value>, AppError> {
386 let snapshot = state
387 .get_latest_snapshot(&id)
388 .await?
389 .ok_or_else(|| AppError::NotFound(format!("state for workflow {id}")))?;
390
391 let parsed: serde_json::Value =
392 serde_json::from_str(&snapshot.state_json).unwrap_or(serde_json::Value::Null);
393
394 Ok(Json(serde_json::json!({
395 "state": parsed,
396 "event_seq": snapshot.event_seq,
397 "created_at": snapshot.created_at,
398 })))
399}
400
401#[utoipa::path(
406 get, path = "/api/v1/engine/workflow/workflows/{id}/state/{name}",
407 tag = "workflows",
408 params(
409 ("id" = String, Path, description = "Workflow ID"),
410 ("name" = String, Path, description = "Query handler name"),
411 ),
412 responses(
413 (status = 200, description = "Query value"),
414 (status = 404, description = "No snapshot or key not present"),
415 ),
416)]
417pub async fn get_workflow_state_by_name<S: WorkflowStore>(
418 State(state): State<Arc<WorkflowCtx<S>>>,
419 Path((id, name)): Path<(String, String)>,
420) -> Result<Json<serde_json::Value>, AppError> {
421 let snapshot = state
422 .get_latest_snapshot(&id)
423 .await?
424 .ok_or_else(|| AppError::NotFound(format!("state for workflow {id}")))?;
425
426 let parsed: serde_json::Value =
427 serde_json::from_str(&snapshot.state_json).unwrap_or(serde_json::Value::Null);
428
429 let value = parsed
430 .get(&name)
431 .cloned()
432 .ok_or_else(|| AppError::NotFound(format!("query '{name}' for workflow {id}")))?;
433
434 Ok(Json(serde_json::json!({
435 "value": value,
436 "event_seq": snapshot.event_seq,
437 "created_at": snapshot.created_at,
438 })))
439}
440
441pub enum AppError {
444 Internal(anyhow::Error),
445 NotFound(String),
446}
447
448impl From<anyhow::Error> for AppError {
449 fn from(e: anyhow::Error) -> Self {
450 Self::Internal(e)
451 }
452}
453
454impl From<serde_json::Error> for AppError {
455 fn from(e: serde_json::Error) -> Self {
456 Self::Internal(e.into())
457 }
458}
459
460impl axum::response::IntoResponse for AppError {
461 fn into_response(self) -> axum::response::Response {
462 match self {
463 Self::Internal(e) => {
464 tracing::error!("Internal error: {e}");
465 (
466 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
467 Json(serde_json::json!({ "error": e.to_string() })),
468 )
469 .into_response()
470 }
471 Self::NotFound(msg) => (
472 axum::http::StatusCode::NOT_FOUND,
473 Json(serde_json::json!({ "error": format!("not found: {msg}") })),
474 )
475 .into_response(),
476 }
477 }
478}
479
480use crate::types::{WorkflowEvent, WorkflowRecord};