1use axum::{
4 Router,
5 extract::DefaultBodyLimit,
6 http::{HeaderName, HeaderValue, Method, StatusCode, header},
7 routing::{any, get, post},
8};
9use tower_http::cors::CorsLayer;
10
11use super::authoring::compile_source;
12use super::deploy::{list_versions, route_version, unload_version, upload_package};
13use super::dev_ui::{dev_register_mock, dev_replay_run, dev_trigger_run};
14use super::events::subscribe_events_socket;
15use super::schedules::{
16 create_schedule, delete_schedule, describe_schedule, list_schedules, pause_schedule,
17 resume_schedule, update_schedule,
18};
19use super::workflows::{
20 cancel_workflow, count_workflows, describe_workflow, get_workflows, list_namespaces,
21 post_list_workflows, query_workflow, signal_workflow, start_workflow,
22};
23use crate::{ServerError, ServerState, dashboard::assets, observability};
24
25pub fn http_router(state: ServerState) -> Result<Router, ServerError> {
32 let dashboard = assets::dashboard_router(&state.runtime_config().dashboard)?;
33 let cors = cors_layer(&state.runtime_config().cors_allowed_origins)?;
34 let metrics = state.metrics().cloned();
35 let health = state.health().cloned();
36 let mut router = workflow_router(state);
37 if let Some(metrics) = metrics {
38 router = router.merge(Router::new().route(
39 "/metrics",
40 get(observability::metrics::metrics_handler).with_state(metrics),
41 ));
42 }
43 if let Some(health) = health {
44 router = router.merge(
45 Router::new()
46 .route("/health/live", get(observability::health::live))
47 .route(
48 "/health/ready",
49 get(observability::health::ready).with_state(health),
50 ),
51 );
52 }
53 let router = router.merge(dashboard);
54 Ok(match cors {
60 Some(cors) => router.layer(cors),
61 None => router,
62 })
63}
64
65fn cors_layer(allowed_origins: &[String]) -> Result<Option<CorsLayer>, ServerError> {
81 if allowed_origins.is_empty() {
82 return Ok(None);
83 }
84 let mut origins = Vec::with_capacity(allowed_origins.len());
85 for origin in allowed_origins {
86 let value = origin
87 .parse::<HeaderValue>()
88 .map_err(|source| ServerError::Config {
89 message: format!("invalid CORS origin `{origin}`: {source}"),
90 })?;
91 origins.push(value);
92 }
93 let layer = CorsLayer::new()
94 .allow_origin(origins)
95 .allow_methods([Method::GET, Method::POST, Method::OPTIONS])
96 .allow_headers([
97 header::CONTENT_TYPE,
98 header::AUTHORIZATION,
99 HeaderName::from_static("x-aion-namespaces"),
100 HeaderName::from_static("x-aion-subject"),
101 ]);
102 Ok(Some(layer))
103}
104
105async fn deploy_disabled() -> StatusCode {
108 StatusCode::NOT_FOUND
109}
110
111async fn authoring_disabled() -> StatusCode {
115 StatusCode::NOT_FOUND
116}
117
118async fn dev_disabled() -> StatusCode {
122 StatusCode::NOT_FOUND
123}
124
125pub fn workflow_router(state: ServerState) -> Router {
127 let deploy = if state.runtime_config().deploy.enabled {
135 Router::new()
136 .route(
137 "/deploy/packages",
138 post(upload_package).layer(DefaultBodyLimit::disable()),
139 )
140 .route("/deploy/versions", get(list_versions))
141 .route("/deploy/route", post(route_version))
142 .route("/deploy/unload", post(unload_version))
143 } else {
144 Router::new().route("/deploy/{*rest}", any(deploy_disabled))
145 };
146 let authoring = if state.runtime_config().authoring.gleam_path.is_some() {
153 Router::new().route("/authoring/compile", post(compile_source))
154 } else {
155 Router::new().route("/authoring/{*rest}", any(authoring_disabled))
156 };
157 let dev = if state.runtime_config().dev.enabled {
162 Router::new()
163 .route("/dev/runs", post(dev_trigger_run))
164 .route("/dev/mocks", post(dev_register_mock))
165 .route("/dev/replay", post(dev_replay_run))
166 } else {
167 Router::new().route("/dev/{*rest}", any(dev_disabled))
168 };
169 deploy
170 .merge(authoring)
171 .merge(dev)
172 .route("/namespaces", get(list_namespaces))
173 .route("/workflows", get(get_workflows))
174 .route("/workflows/count", get(count_workflows))
175 .route("/workflows/start", post(start_workflow))
176 .route("/workflows/signal", post(signal_workflow))
177 .route("/workflows/query", post(query_workflow))
178 .route("/workflows/cancel", post(cancel_workflow))
179 .route("/workflows/list", post(post_list_workflows))
180 .route("/workflows/describe", post(describe_workflow))
181 .route("/events/stream", get(subscribe_events_socket))
182 .route("/schedules", post(create_schedule).get(list_schedules))
183 .route(
184 "/schedules/{id}",
185 get(describe_schedule)
186 .put(update_schedule)
187 .delete(delete_schedule),
188 )
189 .route("/schedules/{id}/pause", post(pause_schedule))
190 .route("/schedules/{id}/resume", post(resume_schedule))
191 .with_state(state)
192}
193
194#[cfg(test)]
195mod tests {
196 use std::{fs, sync::Arc};
197
198 use aion::EngineBuilder;
199 use aion_store::{EventStore, InMemoryStore};
200 use axum::{body, http::Request, http::StatusCode};
201 use tower::ServiceExt;
202
203 use super::super::test_support::{
204 NAMESPACE, json_request, read_json, read_text, runtime_config, server_state,
205 };
206 use super::*;
207 use crate::{
208 NamespaceResolver, StaticScheduleNamespaces, StaticWorkflowNamespaces,
209 config::{DashboardAssetSource, DashboardConfig, NamespaceMode},
210 };
211
212 #[tokio::test]
213 async fn dashboard_assets_serve_index_asset_and_do_not_shadow_public_api()
214 -> Result<(), Box<dyn std::error::Error>> {
215 let bundle = tempfile::tempdir()?;
216 fs::write(
217 bundle.path().join("index.html"),
218 "<!doctype html><title>Aion</title><script src=\"/app.js\"></script>",
219 )?;
220 fs::write(bundle.path().join("app.js"), "window.AION = true;")?;
221
222 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
223 let engine = Arc::new(
224 EngineBuilder::new()
225 .store_arc(Arc::clone(&store))
226 .in_memory_visibility()
227 .scheduler_threads(1)
228 .build()
229 .await?,
230 );
231 let resolver = NamespaceResolver::from_parts(
232 NamespaceMode::SharedEngine,
233 Some(engine),
234 Arc::new(StaticWorkflowNamespaces::default()),
235 Arc::new(StaticScheduleNamespaces::default()),
236 );
237 let mut config = runtime_config();
238 config.dashboard = DashboardConfig {
239 source: DashboardAssetSource::FileSystem {
240 asset_path: bundle.path().to_path_buf(),
241 },
242 };
243 let router = http_router(server_state(resolver, config).await?)?;
244
245 let root = router
246 .clone()
247 .oneshot(Request::builder().uri("/").body(body::Body::empty())?)
248 .await?;
249 assert_eq!(root.status(), StatusCode::OK);
250 assert!(read_text(root).await?.contains("<title>Aion</title>"));
251
252 let asset = router
253 .clone()
254 .oneshot(
255 Request::builder()
256 .uri("/app.js")
257 .body(body::Body::empty())?,
258 )
259 .await?;
260 assert_eq!(asset.status(), StatusCode::OK);
261 assert_eq!(read_text(asset).await?, "window.AION = true;");
262
263 let spa = router
264 .clone()
265 .oneshot(
266 Request::builder()
267 .uri("/dashboard/workflows/demo")
268 .body(body::Body::empty())?,
269 )
270 .await?;
271 assert_eq!(spa.status(), StatusCode::OK);
272 assert!(read_text(spa).await?.contains("<title>Aion</title>"));
273
274 let list = serde_json::json!({
275 "namespace": NAMESPACE,
276 "filter": { "workflow_type": "nonexistent" },
277 });
278 let list_response = router
279 .oneshot(json_request("/workflows/list", &list)?)
280 .await?;
281 assert_eq!(list_response.status(), StatusCode::OK);
282 let list_body: serde_json::Value = read_json(list_response).await?;
283 assert!(
284 list_body["summaries"]
285 .as_array()
286 .ok_or("summaries missing")?
287 .is_empty()
288 );
289 Ok(())
290 }
291
292 #[tokio::test]
293 async fn cors_preflight_and_actual_request_carry_allow_headers_for_configured_origin()
294 -> Result<(), Box<dyn std::error::Error>> {
295 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
296 let engine = Arc::new(
297 EngineBuilder::new()
298 .store_arc(Arc::clone(&store))
299 .in_memory_visibility()
300 .scheduler_threads(1)
301 .build()
302 .await?,
303 );
304 let resolver = NamespaceResolver::from_parts(
305 NamespaceMode::SharedEngine,
306 Some(engine),
307 Arc::new(StaticWorkflowNamespaces::default()),
308 Arc::new(StaticScheduleNamespaces::default()),
309 );
310 let mut config = runtime_config();
311 config.cors_allowed_origins = vec!["http://localhost:5173".to_owned()];
312 let router = http_router(server_state(resolver, config).await?)?;
313
314 let preflight = router
317 .clone()
318 .oneshot(
319 Request::builder()
320 .method("OPTIONS")
321 .uri("/workflows/list")
322 .header("origin", "http://localhost:5173")
323 .header("access-control-request-method", "POST")
324 .header("access-control-request-headers", "x-aion-namespaces")
325 .body(body::Body::empty())?,
326 )
327 .await?;
328 assert_eq!(
329 preflight
330 .headers()
331 .get("access-control-allow-origin")
332 .and_then(|value| value.to_str().ok()),
333 Some("http://localhost:5173")
334 );
335
336 let actual = router
338 .oneshot(
339 Request::builder()
340 .uri("/health/live")
341 .header("origin", "http://localhost:5173")
342 .body(body::Body::empty())?,
343 )
344 .await?;
345 assert_eq!(actual.status(), StatusCode::OK);
346 assert_eq!(
347 actual
348 .headers()
349 .get("access-control-allow-origin")
350 .and_then(|value| value.to_str().ok()),
351 Some("http://localhost:5173")
352 );
353 Ok(())
354 }
355
356 #[tokio::test]
357 async fn cors_absent_origins_install_no_layer() -> Result<(), Box<dyn std::error::Error>> {
358 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
359 let engine = Arc::new(
360 EngineBuilder::new()
361 .store_arc(Arc::clone(&store))
362 .in_memory_visibility()
363 .scheduler_threads(1)
364 .build()
365 .await?,
366 );
367 let resolver = NamespaceResolver::from_parts(
368 NamespaceMode::SharedEngine,
369 Some(engine),
370 Arc::new(StaticWorkflowNamespaces::default()),
371 Arc::new(StaticScheduleNamespaces::default()),
372 );
373 let router = http_router(server_state(resolver, runtime_config()).await?)?;
375
376 let response = router
377 .oneshot(
378 Request::builder()
379 .uri("/health/live")
380 .header("origin", "http://localhost:5173")
381 .body(body::Body::empty())?,
382 )
383 .await?;
384 assert_eq!(response.status(), StatusCode::OK);
385 assert!(
386 response
387 .headers()
388 .get("access-control-allow-origin")
389 .is_none(),
390 "no CorsLayer must be installed when no origins are configured"
391 );
392 Ok(())
393 }
394
395 #[tokio::test]
396 async fn observability_routes_are_public_and_expose_expected_payloads()
397 -> Result<(), Box<dyn std::error::Error>> {
398 #[cfg(feature = "auth")]
402 let config = {
403 let mut config = runtime_config();
404 config.auth.jwks_url = Some(crate::auth::test_support::serve_jwks()?);
405 config
406 };
407 #[cfg(not(feature = "auth"))]
408 let config = runtime_config();
409 let router = http_router(
410 crate::ServerState::build_with_store(InMemoryStore::default(), config).await?,
411 )?;
412
413 let metrics_response = router
414 .clone()
415 .oneshot(
416 Request::builder()
417 .uri("/metrics")
418 .body(body::Body::empty())?,
419 )
420 .await?;
421 assert_eq!(metrics_response.status(), StatusCode::OK);
422 assert_eq!(
423 metrics_response
424 .headers()
425 .get(axum::http::header::CONTENT_TYPE)
426 .and_then(|value| value.to_str().ok()),
427 Some("text/plain; version=0.0.4; charset=utf-8")
428 );
429 let metrics_body = read_text(metrics_response).await?;
430 assert!(metrics_body.contains("# HELP aion_workflows_started_total"));
431 assert!(metrics_body.contains("# TYPE aion_workflows_started_total counter"));
432 assert!(metrics_body.contains("# HELP aion_activity_duration_seconds"));
433 assert!(metrics_body.contains("# TYPE aion_activity_duration_seconds histogram"));
434 assert!(metrics_body.contains("aion_activity_duration_seconds_bucket"));
435 assert!(metrics_body.contains("aion_store_operation_duration_seconds_bucket"));
436
437 let live_response = router
438 .clone()
439 .oneshot(
440 Request::builder()
441 .uri("/health/live")
442 .body(body::Body::empty())?,
443 )
444 .await?;
445 assert_eq!(live_response.status(), StatusCode::OK);
446
447 let ready_response = router
448 .oneshot(
449 Request::builder()
450 .uri("/health/ready")
451 .body(body::Body::empty())?,
452 )
453 .await?;
454 assert_eq!(ready_response.status(), StatusCode::OK);
455 Ok(())
456 }
457}