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