1use std::sync::Arc;
9use std::time::Duration;
10
11use crate::app::ScopedGroup;
12use crate::config::AutumnConfig;
13use crate::error_pages::{self, SharedRenderer};
14use crate::extract::State;
15use crate::idempotency::{IdempotencyLayer, IdempotencyStore, MemoryIdempotencyStore};
16use crate::middleware::RequestIdLayer;
17use crate::middleware::dev;
18use crate::middleware::exception_filter::{
19 ExceptionFilter, ExceptionFilterLayer, ProblemDetailsFilter,
20};
21use crate::route::Route;
22use crate::state::AppState;
23use axum::middleware::Next;
24use axum::response::IntoResponse;
25use http::{Request, StatusCode};
26use thiserror::Error;
27
28pub const DEFAULT_FAVICON_PATH: &str = "/favicon.ico";
29
30#[derive(Debug, Error, PartialEq, Eq)]
35pub enum RouterBuildError {
36 #[error("invalid session backend configuration: {0}")]
38 InvalidSessionBackend(#[from] crate::session::SessionBackendConfigError),
39 #[error("invalid idempotency backend configuration: {0}")]
41 #[allow(dead_code)] InvalidIdempotencyBackend(String),
43 #[error("framework route overlap at {path}: {existing} conflicts with {incoming}")]
45 FrameworkRouteOverlap {
46 path: String,
48 existing: &'static str,
50 incoming: &'static str,
52 },
53 #[cfg(feature = "openapi")]
57 #[error("invalid OpenAPI {field} path: {value:?} (must start with '/' and be non-empty)")]
58 InvalidOpenApiPath {
59 field: &'static str,
61 value: String,
63 },
64 #[cfg(feature = "openapi")]
68 #[error(
69 "openapi_json_path and swagger_ui_path both resolve to {path:?}; they must differ or `swagger_ui_path` must be `None`"
70 )]
71 DuplicateOpenApiPath {
72 path: String,
74 },
75 #[cfg(feature = "openapi")]
78 #[error(
79 "OpenAPI {field} path {path:?} collides with an existing GET route; choose a different `OpenApiConfig::{field}`"
80 )]
81 OpenApiPathCollision {
82 field: &'static str,
84 path: String,
86 },
87 #[error("route '{route_name}' uses unregistered API version '{version}'")]
89 UnregisteredApiVersion { route_name: String, version: String },
90 #[cfg(feature = "mcp")]
94 #[error("invalid MCP mount path: {value:?} (must start with '/' and be non-empty)")]
95 InvalidMcpPath {
96 value: String,
98 },
99 #[cfg(feature = "mcp")]
104 #[error(
105 "MCP mount path {path:?} collides with an existing {method} route; choose a different `mount_mcp` path"
106 )]
107 McpPathCollision {
108 path: String,
110 method: String,
112 },
113}
114
115#[allow(dead_code)]
125pub fn build_router(
126 route_list: Vec<Route>,
127 config: &AutumnConfig,
128 state: AppState,
129) -> axum::Router {
130 try_build_router(route_list, config, state)
131 .unwrap_or_else(|error| panic!("invalid router configuration: {error}"))
132}
133
134pub struct RouterContext {
142 pub exception_filters: Vec<Arc<dyn ExceptionFilter>>,
143 pub scoped_groups: Vec<ScopedGroup>,
144 pub merge_routers: Vec<axum::Router<AppState>>,
145 pub nest_routers: Vec<(String, axum::Router<AppState>)>,
146 pub custom_layers: Vec<crate::app::CustomLayerRegistration>,
159 pub error_page_renderer: Option<SharedRenderer>,
160 pub session_store: Option<Arc<dyn crate::session::BoxedSessionStore>>,
165 #[cfg(feature = "openapi")]
171 pub openapi: Option<crate::openapi::OpenApiConfig>,
172 #[cfg(feature = "mcp")]
179 pub mcp: Option<crate::mcp::McpRuntime>,
180}
181
182pub fn try_build_router(
190 route_list: Vec<Route>,
191 config: &AutumnConfig,
192 state: AppState,
193) -> Result<axum::Router, RouterBuildError> {
194 let startup_barrier_state = state.clone();
195 let router = try_build_router_inner(
196 route_list,
197 config,
198 state,
199 RouterContext {
200 exception_filters: Vec::new(),
201 scoped_groups: Vec::new(),
202 merge_routers: Vec::new(),
203 nest_routers: Vec::new(),
204 custom_layers: Vec::new(),
205 error_page_renderer: None,
206 session_store: None,
207 #[cfg(feature = "openapi")]
208 openapi: None,
209 #[cfg(feature = "mcp")]
210 mcp: None,
211 },
212 )?;
213 Ok(apply_startup_barrier(
214 router,
215 config,
216 &startup_barrier_state,
217 ))
218}
219
220#[allow(dead_code)]
231pub fn build_router_merged(
232 route_list: Vec<Route>,
233 config: &AutumnConfig,
234 state: AppState,
235 merge_routers: Vec<axum::Router<AppState>>,
236 nest_routers: Vec<(String, axum::Router<AppState>)>,
237) -> axum::Router {
238 try_build_router_merged(route_list, config, state, merge_routers, nest_routers)
239 .unwrap_or_else(|error| panic!("invalid router configuration: {error}"))
240}
241
242#[allow(dead_code)]
250pub fn try_build_router_merged(
251 route_list: Vec<Route>,
252 config: &AutumnConfig,
253 state: AppState,
254 merge_routers: Vec<axum::Router<AppState>>,
255 nest_routers: Vec<(String, axum::Router<AppState>)>,
256) -> Result<axum::Router, RouterBuildError> {
257 let startup_barrier_state = state.clone();
258 let router = try_build_router_inner(
259 route_list,
260 config,
261 state,
262 RouterContext {
263 exception_filters: Vec::new(),
264 scoped_groups: Vec::new(),
265 merge_routers,
266 nest_routers,
267 custom_layers: Vec::new(),
268 error_page_renderer: None,
269 session_store: None,
270 #[cfg(feature = "openapi")]
271 openapi: None,
272 #[cfg(feature = "mcp")]
273 mcp: None,
274 },
275 )?;
276 Ok(apply_startup_barrier(
277 router,
278 config,
279 &startup_barrier_state,
280 ))
281}
282
283pub fn try_build_router_inner(
284 route_list: Vec<Route>,
285 config: &AutumnConfig,
286 state: AppState,
287 ctx: RouterContext,
288) -> Result<axum::Router, RouterBuildError> {
289 let router = build_router_pre_state(route_list, config, &state, ctx, None)?;
290 Ok(router.with_state(state))
291}
292
293#[cfg(feature = "mcp")]
296type McpPrepared = (
297 String,
298 Vec<crate::mcp::McpToolInfo>,
299 Option<crate::mcp::McpEndpointLayer>,
300);
301
302#[allow(clippy::too_many_lines)]
307fn build_router_pre_state(
308 route_list: Vec<Route>,
309 config: &AutumnConfig,
310 state: &AppState,
311 #[cfg_attr(not(feature = "mcp"), allow(unused_mut))] mut ctx: RouterContext,
312 opaque_app_layers_override: Option<bool>,
316) -> Result<axum::Router<AppState>, RouterBuildError> {
317 let versions = state.extension::<crate::app::RegisteredApiVersions>();
319 let registered_versions: std::collections::HashSet<&str> = versions
320 .as_ref()
321 .map(|v| v.0.iter().map(|av| av.version.as_str()).collect())
322 .unwrap_or_default();
323
324 let check_route_version = |route: &Route| -> Result<(), RouterBuildError> {
325 if let Some(version) = route
326 .api_version
327 .filter(|ver| !registered_versions.contains(*ver))
328 {
329 return Err(RouterBuildError::UnregisteredApiVersion {
330 route_name: route.name.to_string(),
331 version: version.to_string(),
332 });
333 }
334 Ok(())
335 };
336
337 for route in &route_list {
338 check_route_version(route)?;
339 }
340 for group in &ctx.scoped_groups {
341 for route in &group.routes {
342 check_route_version(route)?;
343 }
344 }
345
346 #[cfg(feature = "openapi")]
350 reject_openapi_path_collisions(
351 ctx.openapi.as_ref(),
352 &route_list,
353 &ctx.scoped_groups,
354 &ctx.merge_routers,
355 &ctx.nest_routers,
356 config,
357 )?;
358
359 #[cfg(feature = "openapi")]
362 let openapi_router = build_openapi_router(
363 &route_list,
364 &ctx.scoped_groups,
365 ctx.openapi.as_ref(),
366 &config.session.cookie_name,
367 versions.as_ref().map_or(&[], |v| v.0.as_slice()),
368 )?;
369
370 #[cfg(feature = "mcp")]
376 let mcp_prepared: Option<McpPrepared> = if let Some(rt) = ctx.mcp.take() {
377 let path = rt.mount_path.as_str();
378 if path.is_empty()
387 || !path.starts_with('/')
388 || path.contains("//")
389 || path.contains('{')
390 || path.contains('*')
391 || path.split('/').any(|segment| segment.starts_with(':'))
392 {
393 return Err(RouterBuildError::InvalidMcpPath {
394 value: rt.mount_path,
395 });
396 }
397 reject_mcp_path_collisions(
402 path,
403 &route_list,
404 &ctx.scoped_groups,
405 config,
406 ctx.openapi.as_ref(),
407 &ctx.merge_routers,
408 &ctx.nest_routers,
409 )?;
410 let docs = collect_openapi_docs(&route_list, &ctx.scoped_groups);
411 let tools = crate::mcp::derive_tools(&docs, rt.expose_all, ctx.openapi.as_ref());
414 Some((rt.mount_path, tools, rt.endpoint_layer))
415 } else {
416 None
417 };
418
419 let idempotency_layers = build_idempotency_layers(config, state)?;
420 let opaque_app_layers_present = opaque_app_layers_override
421 .unwrap_or_else(|| custom_layers_require_fail_closed_idempotency(&ctx.custom_layers));
422 let mut router = group_and_mount_routes(
423 route_list,
424 idempotency_layers.as_ref(),
425 opaque_app_layers_present,
426 state,
427 );
428
429 let dev_reload_enabled = dev::is_enabled_with_env(&crate::config::OsEnv);
430
431 router = mount_framework_routes(router, config, dev_reload_enabled);
432
433 let (mounted_probe_paths, router_with_probes) = mount_probe_endpoints(router, config);
434 router = router_with_probes;
435
436 router = mount_actuator_endpoints(router, config, &mounted_probe_paths)?;
437
438 #[cfg(feature = "openapi")]
439 if let Some(openapi_router) = openapi_router {
440 router = router.merge(openapi_router);
441 }
442
443 let env = crate::config::OsEnv;
448 let static_dir = crate::app::project_dir("static", &env);
449 router = router.nest_service("/static", tower_http::services::ServeDir::new(&static_dir));
450 router = router.layer(axum::middleware::from_fn(asset_cache_control));
451
452 router = mount_scoped_groups(
453 router,
454 ctx.scoped_groups,
455 idempotency_layers.as_ref(),
456 state,
457 );
458
459 router = mount_raw_routers(
460 router,
461 ctx.merge_routers,
462 ctx.nest_routers,
463 idempotency_layers.as_ref(),
464 );
465
466 router = apply_middleware(
467 router,
468 config,
469 state,
470 ctx.exception_filters,
471 ctx.custom_layers,
472 ctx.error_page_renderer,
473 ctx.session_store,
474 )?;
475
476 if dev_reload_enabled {
477 router = router
478 .layer(axum::middleware::from_fn(dev::disable_static_cache))
479 .layer(axum::middleware::from_fn(dev::inject_live_reload));
480 }
481
482 let is_dev_profile = matches!(config.profile.as_deref(), Some("dev" | "development"));
485 if is_dev_profile {
486 router = router.route_layer(axum::middleware::from_fn(
489 crate::middleware::dev::capture_matched_path_middleware,
490 ));
491 }
492 if is_dev_profile {
493 let buf = crate::inspector::InspectorBuffer::new(config.dev.inspector_capacity);
494 let inspector_path = config.dev.inspector_path.clone();
495 let threshold = config.dev.inspector_n_plus_one_threshold;
496
497 router = router.merge(crate::inspector::inspector_router(
499 buf.clone(),
500 &inspector_path,
501 ));
502 tracing::debug!(
503 path = %inspector_path,
504 "Mounted dev request inspector"
505 );
506
507 let layer = crate::inspector::InspectorLayer::new(buf, threshold, inspector_path)
510 .with_session_cookie_name(config.session.cookie_name.clone());
511 router = router.layer(layer);
512 }
513
514 #[cfg(feature = "oauth2")]
515 let router = router.layer(axum::middleware::from_fn_with_state(
516 state.clone(),
517 http_interceptor_middleware,
518 ));
519
520 #[cfg(feature = "mcp")]
539 let router = if let Some((mount_path, tools, endpoint_layer)) = mcp_prepared {
540 let dispatch = router.clone().with_state(state.clone());
541 let tenant_header = (config.tenancy.enabled && config.tenancy.source == "header")
545 .then(|| config.tenancy.header_name.clone());
546 let wiring = crate::mcp::McpWiring {
547 cors: config.cors.clone(),
550 trusted_hosts: TrustedHostPolicy::from_config(config),
553 tenant_header,
554 csrf_header: config.security.csrf.token_header.to_ascii_lowercase(),
557 envelope_rate_limited: config.security.rate_limit.enabled,
561 };
562 let mut mcp_router =
563 crate::mcp::build_mcp_router(&mount_path, tools, dispatch, wiring, endpoint_layer);
564 mcp_router = mcp_router.layer(build_maintenance_layer(config, state));
574 mcp_router = apply_trusted_proxies_middleware(mcp_router, config);
582 mcp_router = mcp_router.layer(axum::extract::DefaultBodyLimit::max(
588 config.security.upload.max_request_size_bytes,
589 ));
590 mcp_router = apply_rate_limit_middleware(mcp_router, config, state);
607 mcp_router = mcp_router.layer(crate::security::SecurityHeadersLayer::from_config(
616 &config.security.headers,
617 ));
618 mcp_router = crate::mcp::apply_mcp_cors_layer(mcp_router, &config.cors);
623 router.merge(mcp_router)
624 } else {
625 router
626 };
627
628 Ok(router)
629}
630
631#[cfg(feature = "openapi")]
637pub fn extract_path_params(path: &str) -> Vec<String> {
638 let mut out = Vec::new();
639 let mut remaining = path;
640
641 while let Some(start) = remaining.find('{') {
642 let after_brace = &remaining[start + 1..];
643 let Some(end_rel) = after_brace.find('}') else {
644 break;
645 };
646
647 let inner = &after_brace[..end_rel];
648 let name = inner.split(':').next().unwrap_or(inner).trim();
649 if !name.is_empty() {
650 out.push(name.to_owned());
651 }
652
653 remaining = &after_brace[end_rel + 1..];
654 }
655
656 out
657}
658
659#[cfg(feature = "openapi")]
662async fn serve_openapi_spec(
663 state: axum::extract::State<AppState>,
664 axum::extract::Extension(config): axum::extract::Extension<
665 std::sync::Arc<crate::openapi::OpenApiConfig>,
666 >,
667 axum::extract::Extension(docs): axum::extract::Extension<
668 std::sync::Arc<Vec<crate::openapi::ApiDoc>>,
669 >,
670) -> impl axum::response::IntoResponse {
671 use axum::response::IntoResponse;
672 let refs: Vec<&crate::openapi::ApiDoc> = docs.iter().collect();
673 let now = state.clock().now();
674 let spec = crate::openapi::generate_spec_at(&config, &refs, now);
675 let spec_json = serde_json::to_string_pretty(&spec)
676 .unwrap_or_else(|e| format!("{{\"error\": \"failed to serialize spec: {e}\"}}"));
677 (
678 [(http::header::CONTENT_TYPE, "application/json")],
679 spec_json,
680 )
681 .into_response()
682}
683
684#[cfg(feature = "openapi")]
692fn build_openapi_router(
693 route_list: &[Route],
694 scoped_groups: &[ScopedGroup],
695 openapi_config: Option<&crate::openapi::OpenApiConfig>,
696 session_cookie_name: &str,
697 api_versions: &[crate::app::ApiVersion],
698) -> Result<Option<axum::Router<AppState>>, RouterBuildError> {
699 let Some(config) = openapi_config else {
700 return Ok(None);
701 };
702 let mut config = config.clone();
703 session_cookie_name.clone_into(&mut config.session_cookie_name);
704 config.api_versions = api_versions.to_vec();
705
706 validate_route_path("openapi_json_path", &config.openapi_json_path)?;
710 if let Some(path) = &config.swagger_ui_path {
711 validate_route_path("swagger_ui_path", path)?;
712 if path == &config.openapi_json_path {
716 return Err(RouterBuildError::DuplicateOpenApiPath { path: path.clone() });
717 }
718 }
719
720 let docs = collect_openapi_docs(route_list, scoped_groups);
721
722 let json_path = config.openapi_json_path.clone();
723 let swagger_path = config.swagger_ui_path.clone();
724 let title = config.title.clone();
725
726 let mut router = axum::Router::<AppState>::new()
727 .route(&json_path, axum::routing::get(serve_openapi_spec))
728 .layer(axum::extract::Extension(std::sync::Arc::new(
729 config.clone(),
730 )))
731 .layer(axum::extract::Extension(std::sync::Arc::new(docs)));
732
733 if let Some(path) = swagger_path {
734 router = mount_swagger_ui_routes(router, &path, &title, &json_path);
735 }
736
737 tracing::debug!(
738 openapi_json = %json_path,
739 swagger_ui = ?config.swagger_ui_path,
740 swagger_ui_version = crate::openapi::SWAGGER_UI_VERSION,
741 "Mounted OpenAPI endpoints"
742 );
743
744 Ok(Some(router))
745}
746
747#[allow(dead_code)]
757pub fn join_nested_path(prefix: &str, child: &str) -> String {
758 let prefix_trimmed = prefix.trim_end_matches('/');
759 if child == "/" || child.is_empty() {
760 if prefix_trimmed.is_empty() {
761 "/".to_owned()
762 } else {
763 prefix_trimmed.to_owned()
764 }
765 } else if child.starts_with('/') {
766 format!("{prefix_trimmed}{child}")
767 } else {
768 format!("{prefix_trimmed}/{child}")
769 }
770}
771
772#[cfg(feature = "openapi")]
788fn validate_route_path(field: &'static str, value: &str) -> Result<(), RouterBuildError> {
789 let reject = |reason_fragment: &str| {
790 Err(RouterBuildError::InvalidOpenApiPath {
791 field,
792 value: format!("{value:?} {reason_fragment}"),
793 })
794 };
795
796 if value.is_empty() {
797 return reject("(must be non-empty)");
798 }
799 if !value.starts_with('/') {
800 return reject("(must start with '/')");
801 }
802 if value.contains("//") {
807 return reject("(must not contain '//')");
808 }
809
810 let mut depth: i32 = 0;
811 for ch in value.chars() {
812 match ch {
813 '{' => depth += 1,
814 '}' => {
815 depth -= 1;
816 if depth < 0 {
817 return reject("(unbalanced '}')");
818 }
819 }
820 '*' => return reject("(wildcard '*' is not allowed in an OpenAPI mount path)"),
821 _ => {}
822 }
823 }
824 if depth != 0 {
825 return reject("(unbalanced '{')");
826 }
827 if value.contains('{') {
828 return reject("(OpenAPI mount paths must be static; `{…}` captures are not allowed)");
829 }
830 Ok(())
831}
832
833#[cfg(feature = "openapi")]
839fn collect_claimed_get_paths(
840 route_list: &[Route],
841 scoped_groups: &[ScopedGroup],
842 config: &AutumnConfig,
843) -> std::collections::HashSet<String> {
844 let mut claimed: std::collections::HashSet<String> = std::collections::HashSet::new();
845 for route in route_list {
846 if route.method == http::Method::GET || route.method.as_str() == "WS" {
847 claimed.insert(route.path.to_owned());
848 }
849 }
850 for group in scoped_groups {
851 for route in &group.routes {
852 if route.method == http::Method::GET || route.method.as_str() == "WS" {
853 claimed.insert(join_nested_path(&group.prefix, route.path));
854 }
855 }
856 }
857 claimed.insert(config.health.path.clone());
859 claimed.insert(config.health.live_path.clone());
860 claimed.insert(config.health.ready_path.clone());
861 claimed.insert(config.health.startup_path.clone());
862 for path in crate::actuator::actuator_endpoint_paths(
863 &config.actuator.prefix,
864 config.actuator.sensitive,
865 config.actuator.prometheus,
866 ) {
867 claimed.insert(path);
868 }
869 #[cfg(feature = "htmx")]
870 {
871 claimed.insert(crate::htmx::HTMX_JS_PATH.to_owned());
872 claimed.insert(crate::htmx::HTMX_CSRF_JS_PATH.to_owned());
873 claimed.insert(crate::htmx::AUTUMN_WIDGETS_JS_PATH.to_owned());
874 }
875 if dev::is_enabled_with_env(&crate::config::OsEnv) {
879 claimed.insert(dev::LIVE_RELOAD_PATH.to_owned());
880 claimed.insert(dev::LIVE_RELOAD_SCRIPT_PATH.to_owned());
881 }
882 if matches!(config.profile.as_deref(), Some("dev" | "development")) {
887 claimed.insert(config.dev.inspector_path.clone());
888 }
889 #[cfg(feature = "mail")]
890 if config
891 .mail
892 .preview_routes_enabled(config.profile.as_deref())
893 {
894 claimed.insert(crate::mail::MAIL_PREVIEW_PATH.to_owned());
895 claimed.insert("/_autumn/mail/messages/{message_id}".to_owned());
896 claimed.insert("/_autumn/mail/previews/{mailer}/{method}".to_owned());
897 }
898 claimed
899}
900
901#[cfg(feature = "mcp")]
911fn reject_mcp_path_collisions(
912 mount_path: &str,
913 route_list: &[Route],
914 scoped_groups: &[ScopedGroup],
915 config: &AutumnConfig,
916 openapi: Option<&crate::openapi::OpenApiConfig>,
917 merge_routers: &[axum::Router<AppState>],
918 nest_routers: &[(String, axum::Router<AppState>)],
919) -> Result<(), RouterBuildError> {
920 let mut claimed_get = collect_claimed_get_paths(route_list, scoped_groups, config);
921 if let Some(openapi) = openapi {
924 claimed_get.insert(openapi.openapi_json_path.clone());
925 if let Some(ui_path) = &openapi.swagger_ui_path {
926 claimed_get.insert(ui_path.clone());
927 claimed_get.extend(crate::openapi::swagger_ui_asset_paths(ui_path));
928 }
929 }
930 if claimed_get.contains(mount_path) {
931 return Err(RouterBuildError::McpPathCollision {
932 path: mount_path.to_owned(),
933 method: "GET".to_owned(),
934 });
935 }
936 let post_owns_path = route_list
938 .iter()
939 .any(|route| route.method == http::Method::POST && route.path == mount_path)
940 || scoped_groups.iter().any(|group| {
941 group.routes.iter().any(|route| {
942 route.method == http::Method::POST
943 && join_nested_path(&group.prefix, route.path) == mount_path
944 })
945 });
946 if post_owns_path {
947 return Err(RouterBuildError::McpPathCollision {
948 path: mount_path.to_owned(),
949 method: "POST".to_owned(),
950 });
951 }
952 let nest_prefixes = nest_routers
959 .iter()
960 .map(|(prefix, _)| prefix.as_str())
961 .chain(std::iter::once("/static"));
962 for prefix in nest_prefixes {
963 let prefix_slash = format!("{prefix}/");
964 if mount_path == prefix || mount_path.starts_with(&prefix_slash) {
965 return Err(RouterBuildError::McpPathCollision {
966 path: mount_path.to_owned(),
967 method: "nested router".to_owned(),
968 });
969 }
970 }
971 if !merge_routers.is_empty() {
975 tracing::warn!(
976 mcp_mount_path = %mount_path,
977 merged_routers = merge_routers.len(),
978 "MCP mount collision check skipped for AppBuilder::merge routers: \
979 axum does not expose their route table, so an overlapping handler \
980 will still panic at startup. Choose an MCP mount path that doesn't \
981 overlap with any merged router's handlers."
982 );
983 }
984 Ok(())
985}
986
987#[cfg(feature = "openapi")]
1009fn reject_openapi_path_collisions(
1010 openapi_config: Option<&crate::openapi::OpenApiConfig>,
1011 route_list: &[Route],
1012 scoped_groups: &[ScopedGroup],
1013 merge_routers: &[axum::Router<AppState>],
1014 nest_routers: &[(String, axum::Router<AppState>)],
1015 config: &AutumnConfig,
1016) -> Result<(), RouterBuildError> {
1017 let Some(openapi) = openapi_config else {
1018 return Ok(());
1019 };
1020
1021 let claimed = collect_claimed_get_paths(route_list, scoped_groups, config);
1024
1025 check_openapi_path_against(
1026 "openapi_json_path",
1027 &openapi.openapi_json_path,
1028 &claimed,
1029 nest_routers,
1030 )?;
1031 if let Some(path) = &openapi.swagger_ui_path {
1032 check_openapi_path_against("swagger_ui_path", path, &claimed, nest_routers)?;
1033 let mut claimed_with_openapi = claimed;
1034 claimed_with_openapi.insert(openapi.openapi_json_path.clone());
1035 for asset_path in crate::openapi::swagger_ui_asset_paths(path) {
1036 check_openapi_path_against(
1037 "swagger_ui_path",
1038 &asset_path,
1039 &claimed_with_openapi,
1040 nest_routers,
1041 )?;
1042 }
1043 }
1044
1045 if !merge_routers.is_empty() {
1049 tracing::warn!(
1050 openapi_json_path = %openapi.openapi_json_path,
1051 swagger_ui_path = ?openapi.swagger_ui_path,
1052 merged_routers = merge_routers.len(),
1053 "OpenAPI mount collision check skipped for AppBuilder::merge routers: \
1054 axum does not expose their route table, so overlapping GET handlers \
1055 will still panic at startup. Choose OpenAPI paths that don't overlap \
1056 with any merged router's handlers."
1057 );
1058 }
1059
1060 Ok(())
1061}
1062
1063#[cfg(feature = "openapi")]
1067fn check_openapi_path_against(
1068 field: &'static str,
1069 path: &str,
1070 claimed: &std::collections::HashSet<String>,
1071 nest_routers: &[(String, axum::Router<AppState>)],
1072) -> Result<(), RouterBuildError> {
1073 if claimed.contains(path) {
1074 return Err(RouterBuildError::OpenApiPathCollision {
1075 field,
1076 path: path.to_owned(),
1077 });
1078 }
1079 for (prefix, _) in nest_routers {
1085 let prefix_slash = format!("{prefix}/");
1086 if path == prefix || path.starts_with(&prefix_slash) {
1087 return Err(RouterBuildError::OpenApiPathCollision {
1088 field,
1089 path: path.to_owned(),
1090 });
1091 }
1092 }
1093 Ok(())
1094}
1095
1096fn group_and_mount_routes(
1097 route_list: Vec<Route>,
1098 idempotency_layers: Option<&BuiltIdempotencyLayers>,
1099 opaque_app_layers_present: bool,
1100 state: &AppState,
1101) -> axum::Router<AppState> {
1102 let mut grouped: indexmap::IndexMap<&str, axum::routing::MethodRouter<AppState>> =
1107 indexmap::IndexMap::new();
1108 for route in &route_list {
1109 tracing::debug!(
1110 method = %route.method,
1111 path = route.path,
1112 name = route.name,
1113 "Mounted route"
1114 );
1115 }
1116 for route in route_list {
1117 let selected_layer = idempotency_layers
1118 .map(|layers| idempotency_layer_for_route(&route, layers, opaque_app_layers_present));
1119 let mut handler = route.handler;
1120 if let Some(layer) = selected_layer {
1121 handler = handler.layer(layer.clone());
1122 }
1123 if let Some(version) = route.api_version {
1124 handler = handler.layer(axum::middleware::from_fn_with_state(
1125 state.clone(),
1126 api_versioning_middleware,
1127 ));
1128 handler = handler.layer(axum::Extension(RouteVersionMetadata {
1129 version: version.to_string(),
1130 sunset_opt_out: route.sunset_opt_out,
1131 secured: route.api_doc.secured,
1132 required_roles: route.api_doc.required_roles,
1133 has_policy: route.api_doc.has_policy,
1134 }));
1135 }
1136 grouped
1137 .entry(route.path)
1138 .and_modify(|existing| {
1139 *existing = std::mem::take(existing).merge(handler.clone());
1140 })
1141 .or_insert(handler);
1142 }
1143
1144 let mut router = axum::Router::new();
1145 for (path, method_router) in grouped {
1146 router = router.route(path, method_router);
1147 }
1148 router
1149}
1150
1151const fn idempotency_layer_for_route<'a>(
1152 route: &Route,
1153 layers: &'a BuiltIdempotencyLayers,
1154 opaque_app_layers_present: bool,
1155) -> &'a IdempotencyLayer {
1156 if opaque_app_layers_present {
1157 &layers.manual
1158 } else if route_uses_generated_replay_stop(route) {
1159 &layers.route
1160 } else {
1161 &layers.manual
1162 }
1163}
1164
1165const fn route_uses_generated_replay_stop(route: &Route) -> bool {
1166 matches!(
1167 route.idempotency,
1168 crate::route::RouteIdempotency::ReplayThroughInner
1169 )
1170}
1171
1172fn custom_layers_require_fail_closed_idempotency(
1173 custom_layers: &[crate::app::CustomLayerRegistration],
1174) -> bool {
1175 custom_layers
1176 .iter()
1177 .any(|registered| !is_idempotency_transparent_app_layer(registered))
1178}
1179
1180fn is_idempotency_transparent_app_layer(registered: &crate::app::CustomLayerRegistration) -> bool {
1181 registered
1182 .type_name
1183 .starts_with("autumn_web::session::SessionLayer<")
1184 || registered
1185 .type_name
1186 .starts_with("autumn::session::SessionLayer<")
1187 || registered.type_id
1188 == std::any::TypeId::of::<crate::session::SessionLayer<crate::session::MemoryStore>>()
1189 || is_i18n_bundle_extension_layer(registered.type_id)
1190}
1191
1192#[cfg(feature = "i18n")]
1193fn is_i18n_bundle_extension_layer(type_id: std::any::TypeId) -> bool {
1194 type_id == std::any::TypeId::of::<axum::Extension<Arc<crate::i18n::Bundle>>>()
1195}
1196
1197#[cfg(not(feature = "i18n"))]
1198const fn is_i18n_bundle_extension_layer(_type_id: std::any::TypeId) -> bool {
1199 false
1200}
1201
1202#[cfg_attr(not(feature = "mail"), allow(unused_variables))]
1203#[allow(clippy::cognitive_complexity)]
1204fn mount_framework_routes(
1205 mut router: axum::Router<AppState>,
1206 config: &AutumnConfig,
1207 dev_reload_enabled: bool,
1208) -> axum::Router<AppState> {
1209 #[cfg(not(feature = "mail"))]
1210 let _ = config;
1211
1212 #[cfg(feature = "htmx")]
1214 {
1215 router = router.route(crate::htmx::HTMX_JS_PATH, axum::routing::get(htmx_handler));
1216 router = router.route(
1217 crate::htmx::HTMX_CSRF_JS_PATH,
1218 axum::routing::get(htmx_csrf_handler),
1219 );
1220 router = router.route(
1221 crate::htmx::AUTUMN_WIDGETS_JS_PATH,
1222 axum::routing::get(autumn_widgets_handler),
1223 );
1224 tracing::debug!(
1225 method = "GET",
1226 path = crate::htmx::HTMX_JS_PATH,
1227 name = format!("htmx {}", crate::htmx::HTMX_VERSION),
1228 "Mounted route"
1229 );
1230 tracing::debug!(
1231 method = "GET",
1232 path = crate::htmx::HTMX_CSRF_JS_PATH,
1233 name = "htmx csrf helper",
1234 "Mounted route"
1235 );
1236 tracing::debug!(
1237 method = "GET",
1238 path = crate::htmx::AUTUMN_WIDGETS_JS_PATH,
1239 name = "autumn widget runtime",
1240 "Mounted route"
1241 );
1242 }
1243
1244 if dev_reload_enabled {
1245 router = router.route(
1246 dev::LIVE_RELOAD_PATH,
1247 axum::routing::get(dev::live_reload_state_handler),
1248 );
1249 router = router.route(
1250 dev::LIVE_RELOAD_SCRIPT_PATH,
1251 axum::routing::get(dev::live_reload_script_handler),
1252 );
1253 tracing::debug!(
1254 state_path = dev::LIVE_RELOAD_PATH,
1255 script_path = dev::LIVE_RELOAD_SCRIPT_PATH,
1256 "Mounted dev live reload endpoints"
1257 );
1258 }
1259
1260 #[cfg(feature = "mail")]
1261 if config
1262 .mail
1263 .preview_routes_enabled(config.profile.as_deref())
1264 {
1265 router = router.merge(crate::mail::mail_preview_router(
1266 config.mail.file_dir.clone(),
1267 ));
1268 tracing::debug!(
1269 path = crate::mail::MAIL_PREVIEW_PATH,
1270 "Mounted dev mail preview endpoints"
1271 );
1272 }
1273
1274 router
1275}
1276
1277fn mount_probe_endpoints<S>(
1278 mut router: axum::Router<S>,
1279 config: &AutumnConfig,
1280) -> (std::collections::HashSet<String>, axum::Router<S>)
1281where
1282 S: Clone + Send + Sync + 'static,
1283 AppState: axum::extract::FromRef<S>,
1284{
1285 let mut mounted_probe_paths = std::collections::HashSet::new();
1287
1288 if mounted_probe_paths.insert(config.health.live_path.clone()) {
1289 router = router.route(
1290 &config.health.live_path,
1291 axum::routing::get(crate::probe::live_handler::<AppState>),
1292 );
1293 }
1294 if mounted_probe_paths.insert(config.health.ready_path.clone()) {
1295 router = router.route(
1296 &config.health.ready_path,
1297 axum::routing::get(crate::probe::ready_handler::<AppState>),
1298 );
1299 }
1300 if mounted_probe_paths.insert(config.health.startup_path.clone()) {
1301 router = router.route(
1302 &config.health.startup_path,
1303 axum::routing::get(crate::probe::startup_handler::<AppState>),
1304 );
1305 }
1306 if mounted_probe_paths.insert(config.health.path.clone()) {
1307 router = router.route(
1308 &config.health.path,
1309 axum::routing::get(crate::health::handler::<AppState>),
1310 );
1311 }
1312 tracing::debug!(
1313 health = %config.health.path,
1314 live = %config.health.live_path,
1315 ready = %config.health.ready_path,
1316 startup = %config.health.startup_path,
1317 "Mounted probe endpoints"
1318 );
1319
1320 (mounted_probe_paths, router)
1321}
1322
1323fn mount_actuator_endpoints(
1324 mut router: axum::Router<AppState>,
1325 config: &AutumnConfig,
1326 mounted_probe_paths: &std::collections::HashSet<String>,
1327) -> Result<axum::Router<AppState>, RouterBuildError> {
1328 let actuator_sensitive = config.actuator.sensitive;
1330 let actuator_prometheus = config.actuator.prometheus;
1331 let actuator_paths = crate::actuator::actuator_endpoint_paths(
1332 &config.actuator.prefix,
1333 actuator_sensitive,
1334 actuator_prometheus,
1335 );
1336 if let Some(path) = actuator_paths
1337 .iter()
1338 .find(|path| mounted_probe_paths.contains(path.as_str()))
1339 {
1340 return Err(RouterBuildError::FrameworkRouteOverlap {
1341 path: path.clone(),
1342 existing: "probe endpoint",
1343 incoming: "actuator endpoint",
1344 });
1345 }
1346 router = router.merge(crate::actuator::actuator_router_with_prefix(
1347 &config.actuator.prefix,
1348 actuator_sensitive,
1349 actuator_prometheus,
1350 ));
1351 tracing::debug!(
1352 sensitive = actuator_sensitive,
1353 prometheus = actuator_prometheus,
1354 prefix = %config.actuator.prefix,
1355 "Mounted actuator endpoints"
1356 );
1357 Ok(router)
1358}
1359
1360fn mount_scoped_groups(
1361 mut router: axum::Router<AppState>,
1362 scoped_groups: Vec<ScopedGroup>,
1363 idempotency_layers: Option<&BuiltIdempotencyLayers>,
1364 state: &AppState,
1365) -> axum::Router<AppState> {
1366 for group in scoped_groups {
1368 let mut sub_router = axum::Router::new();
1369 for route in group.routes {
1370 tracing::debug!(
1371 method = %route.method,
1372 path = route.path,
1373 name = route.name,
1374 scope = %group.prefix,
1375 "Mounted scoped route"
1376 );
1377 let selected_layer = idempotency_layers.map(|layers| &layers.manual);
1384 let mut handler = route.handler;
1385 if let Some(layer) = selected_layer {
1386 handler = handler.layer(layer.clone());
1387 }
1388 if let Some(version) = route.api_version {
1389 handler = handler.layer(axum::middleware::from_fn_with_state(
1390 state.clone(),
1391 api_versioning_middleware,
1392 ));
1393 handler = handler.layer(axum::Extension(RouteVersionMetadata {
1394 version: version.to_string(),
1395 sunset_opt_out: route.sunset_opt_out,
1396 secured: route.api_doc.secured,
1397 required_roles: route.api_doc.required_roles,
1398 has_policy: route.api_doc.has_policy,
1399 }));
1400 }
1401 sub_router = sub_router.route(route.path, handler);
1402 }
1403 sub_router = (group.apply_layer)(sub_router);
1404 router = router.nest(&group.prefix, sub_router);
1405 }
1406 router
1407}
1408
1409fn mount_raw_routers(
1410 mut router: axum::Router<AppState>,
1411 merge_routers: Vec<axum::Router<AppState>>,
1412 nest_routers: Vec<(String, axum::Router<AppState>)>,
1413 idempotency_layers: Option<&BuiltIdempotencyLayers>,
1414) -> axum::Router<AppState> {
1415 for raw_router in merge_routers {
1418 tracing::debug!("Merged raw Axum router");
1419 let raw_router = if let Some(layers) = idempotency_layers {
1420 raw_router.layer(layers.manual.clone())
1421 } else {
1422 raw_router
1423 };
1424 router = router.merge(raw_router);
1425 }
1426
1427 for (prefix, raw_router) in nest_routers {
1429 tracing::debug!(prefix = %prefix, "Nested raw Axum router");
1430 let nested_router =
1433 raw_router.fallback(crate::middleware::error_page_filter::fallback_404_handler);
1434 let nested_router = if let Some(layers) = idempotency_layers {
1435 nested_router.layer(layers.manual.clone())
1436 } else {
1437 nested_router
1438 };
1439 router = router.nest(&prefix, nested_router);
1440 }
1441 router
1442}
1443
1444fn apply_compression_middleware<S>(
1445 mut router: axum::Router<S>,
1446 config: &AutumnConfig,
1447) -> axum::Router<S>
1448where
1449 S: Clone + Send + Sync + 'static,
1450{
1451 if config.compression.enabled {
1452 use tower_http::compression::predicate::{DefaultPredicate, NotForContentType, Predicate};
1453 let predicate = DefaultPredicate::new()
1457 .and(NotForContentType::const_new("audio/"))
1459 .and(NotForContentType::const_new("video/"))
1460 .and(NotForContentType::const_new("application/octet-stream"))
1461 .and(NotForContentType::const_new("application/zip"))
1463 .and(NotForContentType::const_new("application/gzip"))
1464 .and(NotForContentType::const_new("application/x-gzip"))
1465 .and(NotForContentType::const_new("application/zstd"))
1466 .and(NotForContentType::const_new("application/x-bzip2"))
1467 .and(NotForContentType::const_new("application/x-bzip"))
1468 .and(NotForContentType::const_new("application/x-rar-compressed"))
1469 .and(NotForContentType::const_new("application/vnd.rar"))
1470 .and(NotForContentType::const_new("application/x-7z-compressed"));
1471 router =
1472 router.layer(tower_http::compression::CompressionLayer::new().compress_when(predicate));
1473 tracing::info!("Response compression enabled (gzip/brotli)");
1474 }
1475 router
1476}
1477
1478fn apply_cors_middleware<S>(mut router: axum::Router<S>, config: &AutumnConfig) -> axum::Router<S>
1479where
1480 S: Clone + Send + Sync + 'static,
1481{
1482 if !config.cors.allowed_origins.is_empty() {
1484 let cors = build_cors_layer(&config.cors);
1485 tracing::info!(
1486 origins = ?config.cors.allowed_origins,
1487 credentials = config.cors.allow_credentials,
1488 "CORS enabled"
1489 );
1490 router = router.layer(cors);
1491 }
1492 router
1493}
1494
1495fn apply_csrf_middleware<S>(
1496 mut router: axum::Router<S>,
1497 config: &AutumnConfig,
1498 signing_keys: Option<std::sync::Arc<crate::security::config::ResolvedSigningKeys>>,
1499) -> axum::Router<S>
1500where
1501 S: Clone + Send + Sync + 'static,
1502{
1503 if config.security.csrf.enabled {
1505 let mut csrf_layer = crate::security::CsrfLayer::from_config(&config.security.csrf)
1506 .with_max_scan_bytes(config.security.upload.max_request_size_bytes);
1507 if let Some(keys) = signing_keys {
1508 csrf_layer = csrf_layer.with_signing_keys(keys);
1509 }
1510 for endpoint in &config.security.webhooks.endpoints {
1511 csrf_layer = csrf_layer.with_exempt_path(&endpoint.path);
1512 }
1513 tracing::info!("CSRF protection enabled");
1514 router = router.layer(csrf_layer);
1515 }
1516 router
1517}
1518
1519fn apply_bot_protection_middleware<S>(
1520 mut router: axum::Router<S>,
1521 config: &AutumnConfig,
1522) -> axum::Router<S>
1523where
1524 S: Clone + Send + Sync + 'static,
1525{
1526 if config.bot_protection.enabled {
1527 let mut exempt = config.security.captcha_exempt_paths.clone();
1531 for endpoint in &config.security.webhooks.endpoints {
1532 exempt.push(endpoint.path.clone());
1533 }
1534 let layer =
1535 crate::security::captcha::BotProtectionLayer::from_config(&config.bot_protection)
1536 .with_max_scan_bytes(config.security.upload.max_request_size_bytes)
1537 .with_exempt_paths(exempt);
1538 tracing::info!(
1539 provider = ?config.bot_protection.provider,
1540 dev_bypass = config.bot_protection.dev_bypass,
1541 "Bot protection (CAPTCHA) enabled"
1542 );
1543 router = router.layer(layer);
1544 }
1545 router
1546}
1547
1548async fn populate_rate_limit_principal(
1549 axum::extract::State(state): axum::extract::State<AppState>,
1550 mut req: axum::extract::Request,
1551 next: axum::middleware::Next,
1552) -> axum::response::Response {
1553 if let Some(session) = req.extensions().get::<crate::session::Session>() {
1554 let auth_session_key = state.auth_session_key();
1555 if let Some(user_id) = session.get(auth_session_key).await {
1556 req.extensions_mut()
1557 .insert(crate::security::RateLimitPrincipal(user_id));
1558 }
1559 }
1560 next.run(req).await
1561}
1562
1563fn apply_trusted_proxies_middleware<S>(
1564 router: axum::Router<S>,
1565 config: &AutumnConfig,
1566) -> axum::Router<S>
1567where
1568 S: Clone + Send + Sync + 'static,
1569{
1570 let tp = &config.security.trusted_proxies;
1571 let layer = crate::security::TrustedProxiesLayer::from_config(tp);
1572 if tp.trust_forwarded_headers || !tp.ranges.is_empty() || tp.trusted_hops.is_some() {
1573 tracing::info!(
1574 ranges = ?tp.ranges,
1575 trusted_hops = ?tp.trusted_hops,
1576 "Centralized trusted-proxy resolution enabled"
1577 );
1578 }
1579 router.layer(layer)
1580}
1581
1582fn apply_rate_limit_middleware(
1583 mut router: axum::Router<AppState>,
1584 config: &AutumnConfig,
1585 state: &AppState,
1586) -> axum::Router<AppState> {
1587 if config.security.rate_limit.enabled {
1588 let tp = &config.security.trusted_proxies;
1589 let rl = &config.security.rate_limit;
1590 let has_top_level_proxy_config =
1591 tp.trust_forwarded_headers || !tp.ranges.is_empty() || tp.trusted_hops.is_some();
1592 let has_rate_limit_proxy_config =
1597 rl.trust_forwarded_headers || !rl.trusted_proxies.is_empty();
1598 let mut layer = crate::security::RateLimitLayer::from_config(rl).honoring_mcp_exempt();
1603 if has_top_level_proxy_config && !has_rate_limit_proxy_config {
1604 let resolver = crate::security::ProxyResolver::from_config(tp);
1605 layer = layer.with_proxy_resolver(resolver);
1606 }
1607 tracing::info!(
1608 rps = config.security.rate_limit.requests_per_second,
1609 burst = config.security.rate_limit.burst,
1610 "Rate limiting enabled"
1611 );
1612 router = router.layer(layer);
1613
1614 if config.security.rate_limit.key_strategy
1615 == crate::security::KeyStrategy::AuthenticatedPrincipal
1616 {
1617 router = router.layer(axum::middleware::from_fn_with_state(
1618 state.clone(),
1619 populate_rate_limit_principal,
1620 ));
1621 }
1622 }
1623 router
1624}
1625
1626fn apply_upload_middleware<S>(router: axum::Router<S>, config: &AutumnConfig) -> axum::Router<S>
1627where
1628 S: Clone + Send + Sync + 'static,
1629{
1630 let upload_config = config.security.upload.clone();
1631 let max_request_size = upload_config.max_request_size_bytes;
1632 tracing::info!(
1633 max_request_size_bytes = max_request_size,
1634 max_file_size_bytes = upload_config.max_file_size_bytes,
1635 allowed_mime_types = ?upload_config.allowed_mime_types,
1636 "Request body size limits enabled (applies to all content types)"
1637 );
1638
1639 let router = router.layer(axum::extract::DefaultBodyLimit::max(max_request_size));
1642
1643 router.layer(axum::middleware::from_fn(
1646 move |mut req: axum::extract::Request, next: axum::middleware::Next| {
1647 let upload_config = upload_config.clone();
1648 async move {
1649 req.extensions_mut().insert(upload_config);
1650 next.run(req).await
1651 }
1652 },
1653 ))
1654}
1655
1656fn build_maintenance_layer(
1665 config: &AutumnConfig,
1666 state: &AppState,
1667) -> crate::middleware::maintenance::MaintenanceLayer {
1668 let maintenance_state = state
1669 .extension::<crate::maintenance::MaintenanceState>()
1670 .map(|s| (*s).clone())
1671 .unwrap_or_default();
1672 let bypass_paths = vec![
1673 config.health.path.clone(),
1674 config.health.live_path.clone(),
1675 config.health.ready_path.clone(),
1676 config.health.startup_path.clone(),
1677 crate::actuator::actuator_route_path(&config.actuator.prefix, "/health"),
1678 ];
1679 crate::middleware::maintenance::MaintenanceLayer::new(maintenance_state)
1680 .with_health_prefix(config.actuator.prefix.clone())
1681 .with_probe_paths(bypass_paths)
1682}
1683
1684fn apply_request_timeout_middleware(
1691 router: axum::Router<AppState>,
1692 config: &AutumnConfig,
1693 metrics: crate::middleware::MetricsCollector,
1694) -> axum::Router<AppState> {
1695 let timeout_ms = match config.server.timeouts.request_timeout_ms {
1696 Some(ms) if ms > 0 => ms,
1697 _ => return router,
1698 };
1699 let duration = std::time::Duration::from_millis(timeout_ms);
1700 let is_dev = matches!(
1701 config.profile.as_deref(),
1702 Some("dev" | "development") | None
1703 );
1704 tracing::info!(timeout_ms, "Per-request timeout enabled");
1705 router.layer(axum::middleware::from_fn(move |req, next| {
1706 request_timeout_handler(req, next, duration, metrics.clone(), is_dev)
1707 }))
1708}
1709
1710async fn request_timeout_handler(
1711 req: axum::extract::Request,
1712 next: axum::middleware::Next,
1713 duration: std::time::Duration,
1714 metrics: crate::middleware::MetricsCollector,
1715 is_dev: bool,
1716) -> axum::response::Response {
1717 let request_id = req
1718 .extensions()
1719 .get::<crate::middleware::RequestId>()
1720 .cloned();
1721 match tokio::time::timeout(duration, next.run(req)).await {
1722 Ok(response) => response,
1723 Err(_elapsed) => {
1724 if let Some(ref rid) = request_id {
1725 tracing::warn!(request_id = %rid, "Request timed out");
1726 } else {
1727 tracing::warn!("Request timed out");
1728 }
1729 metrics.record_request_timeout();
1730 let body = crate::error::problem_details_json_string(
1731 http::StatusCode::REQUEST_TIMEOUT,
1732 "The server did not receive a complete request within the allowed time",
1733 None,
1734 None,
1735 request_id.as_ref().map(ToString::to_string),
1736 None,
1737 is_dev,
1738 );
1739 (
1740 http::StatusCode::REQUEST_TIMEOUT,
1741 [(http::header::CONTENT_TYPE, "application/problem+json")],
1742 body,
1743 )
1744 .into_response()
1745 }
1746 }
1747}
1748
1749struct BuiltIdempotencyLayers {
1750 route: crate::idempotency::IdempotencyLayer,
1751 manual: crate::idempotency::IdempotencyLayer,
1752}
1753
1754fn build_idempotency_layers(
1755 config: &AutumnConfig,
1756 state: &AppState,
1757) -> Result<Option<BuiltIdempotencyLayers>, RouterBuildError> {
1758 if !config.idempotency.enabled.unwrap_or(false) {
1759 return Ok(None);
1760 }
1761
1762 let ttl = Duration::from_secs(config.idempotency.ttl_secs);
1763 let in_flight_ttl = Duration::from_secs(config.idempotency.in_flight_ttl_secs);
1764 let store: std::sync::Arc<dyn IdempotencyStore> = match config.idempotency.backend {
1765 crate::config::IdempotencyBackend::Memory => {
1766 std::sync::Arc::new(MemoryIdempotencyStore::new(ttl))
1767 }
1768 #[cfg(feature = "redis")]
1769 crate::config::IdempotencyBackend::Redis => {
1770 match crate::idempotency::RedisIdempotencyStore::from_config(&config.idempotency) {
1771 Ok(s) => std::sync::Arc::new(s),
1772 Err(e) => return Err(RouterBuildError::InvalidIdempotencyBackend(e)),
1773 }
1774 }
1775 #[cfg(not(feature = "redis"))]
1776 crate::config::IdempotencyBackend::Redis => {
1777 return Err(RouterBuildError::InvalidIdempotencyBackend(
1778 "idempotency backend 'redis' requires the autumn-web 'redis' feature \
1779 flag; rebuild with --features redis or switch to backend = \"memory\""
1780 .to_owned(),
1781 ));
1782 }
1783 };
1784
1785 tracing::debug!(
1786 backend = ?config.idempotency.backend,
1787 ttl_secs = config.idempotency.ttl_secs,
1788 in_flight_ttl_secs = config.idempotency.in_flight_ttl_secs,
1789 "Idempotency-key middleware enabled"
1790 );
1791
1792 let base = IdempotencyLayer::new(store)
1793 .with_ttl(ttl)
1794 .with_in_flight_ttl(in_flight_ttl)
1795 .with_metrics(state.metrics.clone());
1796
1797 Ok(Some(BuiltIdempotencyLayers {
1798 route: base.clone().replay_through_inner(),
1799 manual: base.fail_closed_on_replay(),
1800 }))
1801}
1802
1803#[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
1804fn apply_middleware(
1805 mut router: axum::Router<AppState>,
1806 config: &AutumnConfig,
1807 state: &AppState,
1808 exception_filters: Vec<Arc<dyn ExceptionFilter>>,
1809 custom_layers: Vec<crate::app::CustomLayerRegistration>,
1810 error_page_renderer: Option<SharedRenderer>,
1811 session_store: Option<Arc<dyn crate::session::BoxedSessionStore>>,
1812) -> Result<axum::Router<AppState>, RouterBuildError> {
1813 router = router.fallback(crate::middleware::error_page_filter::fallback_404_handler);
1816
1817 let is_production = matches!(config.profile.as_deref(), Some("prod" | "production"));
1819 let signing_keys = std::sync::Arc::new(crate::security::config::resolve_signing_keys(
1820 &config.security.signing_secret,
1821 ));
1822 let signing_keys_opt: Option<std::sync::Arc<crate::security::config::ResolvedSigningKeys>> =
1826 if config.security.signing_secret.secret.is_some() || is_production {
1827 Some(signing_keys)
1828 } else {
1829 None
1830 };
1831
1832 router = apply_cors_middleware(router, config);
1833 let trusted_host_policy = TrustedHostPolicy::from_config(config);
1834 router = router.layer(axum::middleware::from_fn(move |req, next| {
1835 trusted_host_middleware(req, next, trusted_host_policy.clone())
1836 }));
1837 router = apply_csrf_middleware(router, config, signing_keys_opt.clone());
1838 router = apply_bot_protection_middleware(router, config);
1839 router = router.layer(axum::middleware::from_fn(
1852 crate::middleware::method_override_rejection_filter,
1853 ));
1854 router = apply_rate_limit_middleware(router, config, state);
1855
1856 router = router.layer(build_maintenance_layer(config, state));
1859
1860 router = router.layer(axum::middleware::from_fn(
1861 crate::webhook::webhook_replay_cleanup_middleware,
1862 ));
1863 router = apply_upload_middleware(router, config);
1864
1865 let security_headers =
1867 crate::security::SecurityHeadersLayer::from_config(&config.security.headers);
1868 tracing::debug!("Security headers enabled");
1869
1870 let custom_layer_count = custom_layers.len();
1880 for registered in custom_layers.into_iter().rev() {
1881 router = (registered.apply)(router);
1882 }
1883 if custom_layer_count > 0 {
1884 tracing::debug!(count = custom_layer_count, "Custom Tower layers applied");
1885 }
1886
1887 router = apply_trusted_proxies_middleware(router, config);
1891
1892 let mut router = router;
1893
1894 if config.tenancy.enabled {
1895 router = router.layer(axum::middleware::from_fn_with_state(
1896 state.clone(),
1897 crate::tenancy::tenancy_middleware,
1898 ));
1899 tracing::debug!("Multi-tenancy middleware enabled");
1900 }
1901
1902 router = apply_request_timeout_middleware(router, config, state.metrics.clone());
1912
1913 #[cfg(feature = "reporting")]
1919 {
1920 router = router.layer(crate::reporting::ReportingLayer::new(
1921 state.error_reporters(),
1922 config.reporting.enabled,
1923 config.reporting.sample_rate,
1924 ));
1925 }
1926
1927 if config.log.access_log {
1937 router = router.layer(crate::middleware::AccessLogLayer::new(
1938 config.log.access_log_exclude.clone(),
1939 ));
1940 }
1941
1942 let mut log_context_filter_parameters = config.log.filter_parameters.clone();
1949 log_context_filter_parameters.extend(crate::encryption::registered_encrypted_column_names());
1950 let log_context_filter = Arc::new(crate::log::filter::ParameterFilter::new(
1951 &log_context_filter_parameters,
1952 &config.log.unfilter_parameters,
1953 ));
1954 let router = router.layer(crate::middleware::LogContextLayer::new(log_context_filter));
1955
1956 let router = router.layer(RequestIdLayer).layer(security_headers);
1957
1958 let router = crate::session::apply_session_layer(
1959 router,
1960 &config.session,
1961 config.profile.as_deref(),
1962 session_store,
1963 signing_keys_opt,
1964 )?;
1965 tracing::debug!(backend = ?config.session.backend, "Session management enabled");
1966
1967 let is_dev = config
1970 .profile
1971 .as_deref()
1972 .map_or(cfg!(debug_assertions), |p| p == "dev");
1973 let renderer = error_page_renderer.unwrap_or_else(error_pages::default_renderer);
1974 let mut filter_parameters = config.log.filter_parameters.clone();
1978 filter_parameters.extend(crate::encryption::registered_encrypted_column_names());
1979 let error_page_filter = crate::middleware::error_page_filter::ErrorPageFilter {
1980 renderer,
1981 is_dev,
1982 parameter_filter: crate::log::filter::ParameterFilter::new(
1983 &filter_parameters,
1984 &config.log.unfilter_parameters,
1985 ),
1986 };
1987
1988 let mut all_filters: Vec<Arc<dyn ExceptionFilter>> = vec![
1992 Arc::new(ProblemDetailsFilter { is_dev }),
1993 Arc::new(error_page_filter),
1994 ];
1995 all_filters.extend(exception_filters);
1996
1997 let count = all_filters.len();
1998 tracing::debug!(
1999 count,
2000 "Registered exception filters (including error page filter)"
2001 );
2002
2003 let router = router
2017 .layer(crate::middleware::error_page_filter::ErrorPageContextLayer { is_dev })
2018 .layer(ExceptionFilterLayer::new(all_filters))
2019 .layer(crate::middleware::MetricsLayer::new(state.metrics.clone()));
2020
2021 let router = apply_compression_middleware(router, config);
2030
2031 Ok(router)
2032}
2033
2034async fn trusted_host_middleware(
2035 req: Request<axum::body::Body>,
2036 next: Next,
2037 policy: TrustedHostPolicy,
2038) -> axum::response::Response {
2039 let path = req.uri().path();
2040 if (req.method() == http::Method::GET || req.method() == http::Method::HEAD)
2041 && policy.probe_bypass_paths.contains(path)
2042 {
2043 return next.run(req).await;
2044 }
2045 let authority = req.uri().authority().map(http::uri::Authority::as_str);
2046 let host_header = req
2047 .headers()
2048 .get(http::header::HOST)
2049 .and_then(|v| v.to_str().ok());
2050 let raw_host = authority.or(host_header);
2051 let parsed_host = raw_host.and_then(extract_host_without_port);
2052 let host = parsed_host
2053 .map(str::to_ascii_lowercase)
2054 .map(|h| h.trim_end_matches('.').to_owned())
2055 .filter(|h| !h.is_empty());
2056 let host_source_present = raw_host.is_some();
2057 if host.is_none() && !host_source_present && policy.allow_missing_host {
2058 return next.run(req).await;
2059 }
2060 if host.as_deref().is_some_and(|host| policy.allows_host(host)) {
2061 next.run(req).await
2062 } else {
2063 tracing::warn!(host = ?host, "trusted host rejected request");
2064 let body = crate::error::problem_details_json_string(
2065 StatusCode::BAD_REQUEST,
2066 "Invalid Host header",
2067 None,
2068 None,
2069 None,
2070 None,
2071 true,
2072 );
2073 (
2074 StatusCode::BAD_REQUEST,
2075 [(http::header::CONTENT_TYPE, "application/problem+json")],
2076 body,
2077 )
2078 .into_response()
2079 }
2080}
2081
2082pub fn extract_host_without_port(header: &str) -> Option<&str> {
2083 let host = header.trim();
2084 if host.is_empty() {
2085 return None;
2086 }
2087 if host.starts_with('[') {
2088 let end = host.find(']')?;
2089 let literal = host.get(1..end)?;
2090 if literal.is_empty() || literal.parse::<std::net::IpAddr>().is_err() {
2091 return None;
2092 }
2093
2094 let remainder = host.get(end + 1..)?;
2095 if remainder.is_empty() {
2096 return Some(literal);
2097 }
2098
2099 let maybe_port = remainder.strip_prefix(':')?;
2100 if !maybe_port.is_empty() && maybe_port.chars().all(|c| c.is_ascii_digit()) {
2101 return Some(literal);
2102 }
2103
2104 return None;
2105 }
2106 let Some((candidate, maybe_port)) = host.rsplit_once(':') else {
2107 return Some(host);
2108 };
2109 if candidate.contains(':') {
2110 return Some(host);
2112 }
2113 if !maybe_port.is_empty()
2114 && maybe_port.chars().all(|c| c.is_ascii_digit())
2115 && !candidate.is_empty()
2116 {
2117 Some(candidate)
2118 } else {
2119 None
2120 }
2121}
2122
2123#[allow(dead_code)]
2145pub fn build_router_with_static(
2146 route_list: Vec<Route>,
2147 config: &AutumnConfig,
2148 state: AppState,
2149 dist_dir: Option<&std::path::Path>,
2150) -> axum::Router {
2151 try_build_router_with_static(route_list, config, state, dist_dir)
2152 .unwrap_or_else(|error| panic!("invalid router configuration: {error}"))
2153}
2154
2155#[allow(dead_code)]
2163pub fn try_build_router_with_static(
2164 route_list: Vec<Route>,
2165 config: &AutumnConfig,
2166 state: AppState,
2167 dist_dir: Option<&std::path::Path>,
2168) -> Result<axum::Router, RouterBuildError> {
2169 try_build_router_with_static_inner(
2170 route_list,
2171 config,
2172 state,
2173 dist_dir,
2174 RouterContext {
2175 exception_filters: Vec::new(),
2176 scoped_groups: Vec::new(),
2177 merge_routers: Vec::new(),
2178 nest_routers: Vec::new(),
2179 custom_layers: Vec::new(),
2180 error_page_renderer: None,
2181 session_store: None,
2182 #[cfg(feature = "openapi")]
2183 openapi: None,
2184 #[cfg(feature = "mcp")]
2185 mcp: None,
2186 },
2187 )
2188}
2189
2190pub fn try_build_router_with_static_inner(
2191 route_list: Vec<Route>,
2192 config: &AutumnConfig,
2193 state: AppState,
2194 dist_dir: Option<&std::path::Path>,
2195 mut ctx: RouterContext,
2196) -> Result<axum::Router, RouterBuildError> {
2197 let startup_barrier_state = state.clone();
2198
2199 let Some(dist) = dist_dir else {
2200 let app_router = try_build_router_inner(route_list, config, state, ctx)?;
2201 return Ok(apply_startup_barrier(
2202 app_router,
2203 config,
2204 &startup_barrier_state,
2205 ));
2206 };
2207
2208 let Some(layer) = crate::static_gen::StaticFileLayer::new(dist) else {
2209 tracing::debug!(
2210 dist = %dist.display(),
2211 "No valid manifest.json in dist dir; skipping static file layer"
2212 );
2213 let app_router = try_build_router_inner(route_list, config, state, ctx)?;
2214 return Ok(apply_startup_barrier(
2215 app_router,
2216 config,
2217 &startup_barrier_state,
2218 ));
2219 };
2220
2221 for (route, entry) in &layer.manifest().routes {
2222 tracing::debug!(
2223 route = %route,
2224 file = %entry.file,
2225 revalidate = ?entry.revalidate,
2226 "Static route"
2227 );
2228 }
2229
2230 let opaque_present = Some(custom_layers_require_fail_closed_idempotency(
2242 &ctx.custom_layers,
2243 ));
2244 let custom_layers = std::mem::take(&mut ctx.custom_layers);
2245
2246 let inner_router = build_router_pre_state(route_list, config, &state, ctx, opaque_present)?;
2247
2248 let has_isr = layer
2253 .manifest()
2254 .routes
2255 .values()
2256 .any(|e| e.revalidate.is_some());
2257 let layer = if has_isr {
2258 layer.with_router(inner_router.clone().with_state(state.clone()))
2259 } else {
2260 layer
2261 };
2262 let layer = Arc::new(layer);
2263
2264 let static_layer = layer;
2276 let mut router: axum::Router<AppState> = inner_router.layer(axum::middleware::from_fn(
2277 move |req: axum::extract::Request, next: axum::middleware::Next| {
2278 let static_layer = static_layer.clone();
2279 async move {
2280 let is_get = req.method() == http::Method::GET;
2281 let is_head = req.method() == http::Method::HEAD;
2282 if is_get || is_head {
2283 let path = req.uri().path();
2284 let normalized = if path.len() > 1 && path.ends_with('/') {
2286 &path[..path.len() - 1]
2287 } else {
2288 path
2289 };
2290 if let Some(file_path) = static_layer.resolve(normalized)
2291 && let Ok(contents) = tokio::fs::read(&file_path).await
2292 {
2293 let body = if is_head {
2294 axum::body::Body::empty()
2295 } else {
2296 axum::body::Body::from(contents)
2297 };
2298 return http::Response::builder()
2299 .status(http::StatusCode::OK)
2300 .header(http::header::CONTENT_TYPE, "text/html; charset=utf-8")
2301 .body(body)
2302 .expect("infallible response builder");
2303 }
2304 }
2305 next.run(req).await
2306 }
2307 },
2308 ));
2309
2310 let custom_layer_count = custom_layers.len();
2315 for registered in custom_layers.into_iter().rev() {
2316 router = (registered.apply)(router);
2317 }
2318 if custom_layer_count > 0 {
2319 tracing::debug!(
2320 count = custom_layer_count,
2321 "Custom Tower layers applied outside static middleware"
2322 );
2323 }
2324
2325 router = apply_compression_middleware(router, config);
2330
2331 let router = router.layer(crate::security::SecurityHeadersLayer::from_config(
2332 &config.security.headers,
2333 ));
2334
2335 Ok(apply_startup_barrier(
2336 router.with_state(state),
2337 config,
2338 &startup_barrier_state,
2339 ))
2340}
2341
2342#[derive(Clone)]
2343struct StartupBarrierState {
2344 app_state: AppState,
2345 live_path: String,
2346 ready_path: String,
2347 startup_path: String,
2348 health_path: String,
2349 actuator_paths: Vec<String>,
2350 actuator_subtree_paths: Vec<String>,
2351}
2352
2353impl StartupBarrierState {
2354 fn from_config(config: &AutumnConfig, app_state: &AppState) -> Self {
2355 let actuator_subtree_paths = if config.actuator.sensitive {
2356 vec![crate::actuator::actuator_route_path(
2357 &config.actuator.prefix,
2358 "/loggers",
2359 )]
2360 } else {
2361 Vec::new()
2362 };
2363
2364 Self {
2365 app_state: app_state.clone(),
2366 live_path: config.health.live_path.clone(),
2367 ready_path: config.health.ready_path.clone(),
2368 startup_path: config.health.startup_path.clone(),
2369 health_path: config.health.path.clone(),
2370 actuator_paths: crate::actuator::actuator_endpoint_paths(
2371 &config.actuator.prefix,
2372 config.actuator.sensitive,
2373 config.actuator.prometheus,
2374 ),
2375 actuator_subtree_paths,
2376 }
2377 }
2378
2379 fn allows_path(&self, path: &str) -> bool {
2380 path == self.live_path
2381 || path == self.ready_path
2382 || path == self.startup_path
2383 || path == self.health_path
2384 || self.actuator_paths.iter().any(|allowed| path == allowed)
2385 || self
2386 .actuator_subtree_paths
2387 .iter()
2388 .any(|allowed| path_matches_route_prefix(path, allowed))
2389 }
2390}
2391
2392fn apply_startup_barrier(
2393 router: axum::Router,
2394 config: &AutumnConfig,
2395 state: &AppState,
2396) -> axum::Router {
2397 let barrier_state = StartupBarrierState::from_config(config, state);
2398 let router = router.layer(axum::middleware::from_fn_with_state(
2399 barrier_state,
2400 startup_barrier,
2401 ));
2402 let router = if config.log.access_log {
2413 router.layer(crate::middleware::AccessLogLayer::fallback(
2414 config.log.access_log_exclude.clone(),
2415 ))
2416 } else {
2417 router
2418 };
2419 #[cfg(feature = "telemetry-otlp")]
2428 let router = router.layer(crate::middleware::TraceContextLayer);
2429 router
2430}
2431
2432async fn startup_barrier(
2433 State(state): State<StartupBarrierState>,
2434 request: axum::extract::Request,
2435 next: Next,
2436) -> axum::response::Response {
2437 if crate::app::is_static_build_mode()
2438 || state.app_state.probes().is_startup_complete()
2439 || state.allows_path(request.uri().path())
2440 {
2441 next.run(request).await
2442 } else {
2443 (
2444 StatusCode::SERVICE_UNAVAILABLE,
2445 "Service is still starting up",
2446 )
2447 .into_response()
2448 }
2449}
2450
2451fn path_matches_route_prefix(path: &str, prefix: &str) -> bool {
2452 path == prefix
2453 || path
2454 .strip_prefix(prefix)
2455 .is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
2456}
2457
2458pub fn build_cors_layer(cors: &crate::config::CorsConfig) -> tower_http::cors::CorsLayer {
2462 use http::header::HeaderName;
2463 use tower_http::cors::{AllowOrigin, CorsLayer};
2464
2465 let layer = if cors.allowed_origins.iter().any(|o| o == "*") {
2466 CorsLayer::new().allow_origin(AllowOrigin::any())
2467 } else {
2468 let origins: Vec<http::HeaderValue> = cors
2469 .allowed_origins
2470 .iter()
2471 .filter_map(|o| match o.parse() {
2472 Ok(v) => Some(v),
2473 Err(e) => {
2474 tracing::warn!(origin = %o, error = %e, "CORS: ignoring malformed allowed_origin");
2475 None
2476 }
2477 })
2478 .collect();
2479 CorsLayer::new().allow_origin(origins)
2480 };
2481
2482 let methods: Vec<http::Method> = cors
2483 .allowed_methods
2484 .iter()
2485 .filter_map(|m| match m.parse() {
2486 Ok(v) => Some(v),
2487 Err(e) => {
2488 tracing::warn!(method = %m, error = %e, "CORS: ignoring malformed allowed_method");
2489 None
2490 }
2491 })
2492 .collect();
2493
2494 let headers: Vec<HeaderName> = cors
2495 .allowed_headers
2496 .iter()
2497 .filter_map(|h| match h.parse() {
2498 Ok(v) => Some(v),
2499 Err(e) => {
2500 tracing::warn!(header = %h, error = %e, "CORS: ignoring malformed allowed_header");
2501 None
2502 }
2503 })
2504 .collect();
2505
2506 layer
2507 .allow_methods(methods)
2508 .allow_headers(headers)
2509 .allow_credentials(cors.allow_credentials)
2510 .max_age(std::time::Duration::from_secs(cors.max_age_secs))
2511}
2512
2513pub async fn asset_cache_control(
2527 req: axum::extract::Request,
2528 next: axum::middleware::Next,
2529) -> axum::response::Response {
2530 let path = req.uri().path().to_owned();
2531 let mut resp = next.run(req).await;
2532 if path.starts_with("/static/") && resp.status().is_success() {
2533 let is_immutable = path
2537 .strip_prefix("/static/")
2538 .is_some_and(crate::assets::is_manifest_asset);
2539 let header = if is_immutable {
2540 "public, max-age=31536000, immutable"
2541 } else {
2542 "public, max-age=0, must-revalidate"
2543 };
2544 resp.headers_mut().insert(
2545 http::header::CACHE_CONTROL,
2546 http::HeaderValue::from_static(header),
2547 );
2548 }
2549 resp
2550}
2551
2552#[cfg(feature = "htmx")]
2553pub async fn htmx_handler() -> axum::response::Response {
2554 use axum::response::IntoResponse;
2555 (
2556 [
2557 (http::header::CONTENT_TYPE, "application/javascript"),
2558 (
2559 http::header::CACHE_CONTROL,
2560 "public, max-age=31536000, immutable",
2561 ),
2562 ],
2563 crate::htmx::HTMX_JS,
2564 )
2565 .into_response()
2566}
2567
2568#[cfg(feature = "htmx")]
2569pub async fn htmx_csrf_handler() -> axum::response::Response {
2570 use axum::response::IntoResponse;
2571 (
2572 [
2573 (http::header::CONTENT_TYPE, "application/javascript"),
2574 (
2575 http::header::CACHE_CONTROL,
2576 "public, max-age=31536000, immutable",
2577 ),
2578 ],
2579 crate::htmx::HTMX_CSRF_JS,
2580 )
2581 .into_response()
2582}
2583
2584#[cfg(feature = "htmx")]
2585pub async fn autumn_widgets_handler() -> axum::response::Response {
2586 use axum::response::IntoResponse;
2587 (
2588 [
2589 (http::header::CONTENT_TYPE, "application/javascript"),
2590 (
2591 http::header::CACHE_CONTROL,
2592 "public, max-age=31536000, immutable",
2593 ),
2594 ],
2595 crate::htmx::AUTUMN_WIDGETS_JS,
2596 )
2597 .into_response()
2598}
2599
2600#[cfg(feature = "openapi")]
2601fn collect_openapi_docs(
2602 route_list: &[Route],
2603 scoped_groups: &[ScopedGroup],
2604) -> Vec<crate::openapi::ApiDoc> {
2605 let mut docs: Vec<crate::openapi::ApiDoc> = Vec::new();
2610 for route in route_list {
2611 let mut doc = route.api_doc.clone();
2612 doc.api_version = route.api_version;
2613 doc.sunset_opt_out = route.sunset_opt_out;
2614 docs.push(doc);
2615 }
2616 for group in scoped_groups {
2617 let prefix_params = extract_path_params(&group.prefix);
2621 for route in &group.routes {
2622 let mut doc = route.api_doc.clone();
2623 doc.api_version = route.api_version;
2624 doc.sunset_opt_out = route.sunset_opt_out;
2625 let full = join_nested_path(&group.prefix, route.api_doc.path);
2631 doc.path = Box::leak(full.into_boxed_str());
2632
2633 if !prefix_params.is_empty() {
2634 let mut merged: Vec<&'static str> = prefix_params
2635 .iter()
2636 .map(|p| &*Box::leak(p.clone().into_boxed_str()))
2637 .collect();
2638 for existing in route.api_doc.path_params {
2639 if !merged.iter().any(|n| n == existing) {
2640 merged.push(existing);
2641 }
2642 }
2643 doc.path_params = Box::leak(merged.into_boxed_slice());
2644 }
2645
2646 docs.push(doc);
2647 }
2648 }
2649 docs
2650}
2651
2652#[cfg(feature = "openapi")]
2653fn mount_swagger_ui_routes(
2654 mut router: axum::Router<AppState>,
2655 path: &str,
2656 title: &str,
2657 json_path: &str,
2658) -> axum::Router<AppState> {
2659 let [css_path, bundle_path, initializer_path] = crate::openapi::swagger_ui_asset_paths(path);
2660 let html_body = Arc::new(crate::openapi::swagger_ui_html(
2661 title,
2662 &css_path,
2663 &bundle_path,
2664 &initializer_path,
2665 ));
2666 let initializer_body = Arc::new(crate::openapi::swagger_ui_initializer_js(json_path));
2667 router = router.route(
2668 path,
2669 axum::routing::get(move || {
2670 let html = html_body.clone();
2671 async move {
2672 use axum::response::IntoResponse;
2673 (
2674 [(http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
2675 (*html).clone(),
2676 )
2677 .into_response()
2678 }
2679 }),
2680 );
2681 router = router.route(
2682 &css_path,
2683 axum::routing::get(|| async move {
2684 use axum::response::IntoResponse;
2685 (
2686 [(http::header::CONTENT_TYPE, "text/css; charset=utf-8")],
2687 crate::openapi::SWAGGER_UI_CSS,
2688 )
2689 .into_response()
2690 }),
2691 );
2692 router = router.route(
2693 &bundle_path,
2694 axum::routing::get(|| async move {
2695 use axum::body::Bytes;
2696 use axum::response::IntoResponse;
2697 (
2698 [(
2699 http::header::CONTENT_TYPE,
2700 "application/javascript; charset=utf-8",
2701 )],
2702 Bytes::from_static(crate::openapi::SWAGGER_UI_BUNDLE),
2703 )
2704 .into_response()
2705 }),
2706 );
2707 router = router.route(
2708 &initializer_path,
2709 axum::routing::get(move || {
2710 let js = initializer_body.clone();
2711 async move {
2712 use axum::response::IntoResponse;
2713 (
2714 [(
2715 http::header::CONTENT_TYPE,
2716 "application/javascript; charset=utf-8",
2717 )],
2718 (*js).clone(),
2719 )
2720 .into_response()
2721 }
2722 }),
2723 );
2724 router
2725}
2726
2727#[cfg(feature = "oauth2")]
2728async fn http_interceptor_middleware(
2729 state: axum::extract::State<AppState>,
2730 req: axum::extract::Request,
2731 next: axum::middleware::Next,
2732) -> axum::response::Response {
2733 use crate::interceptor::{ACTIVE_HTTP_INTERCEPTORS, HttpInterceptor};
2734 if let Some(interceptor_arc) = state.extension::<Arc<dyn HttpInterceptor>>() {
2735 let interceptor = (*interceptor_arc).clone();
2736 let interceptors = vec![interceptor];
2737 ACTIVE_HTTP_INTERCEPTORS
2738 .scope(interceptors, async move { next.run(req).await })
2739 .await
2740 } else {
2741 next.run(req).await
2742 }
2743}
2744
2745#[cfg(test)]
2746mod tests {
2747 use super::*;
2748 use axum::body::Body;
2749 use axum::http::{Request, StatusCode};
2750 use tower::ServiceExt;
2751
2752 fn test_state() -> AppState {
2753 AppState {
2754 extensions: std::sync::Arc::new(std::sync::RwLock::new(
2755 std::collections::HashMap::new(),
2756 )),
2757 #[cfg(feature = "db")]
2758 pool: None,
2759 #[cfg(feature = "db")]
2760 replica_pool: None,
2761 profile: Some("test".to_owned()),
2762 started_at: std::time::Instant::now(),
2763 health_detailed: false,
2764 probes: crate::probe::ProbeState::ready_for_test(),
2765 metrics: crate::middleware::MetricsCollector::new(),
2766 log_levels: crate::actuator::LogLevels::new("info"),
2767 task_registry: crate::actuator::TaskRegistry::new(),
2768 job_registry: crate::actuator::JobRegistry::new(),
2769 config_props: crate::actuator::ConfigProperties::default(),
2770 metrics_source_registry: crate::actuator::MetricsSourceRegistry::new(),
2771 health_indicator_registry: crate::actuator::HealthIndicatorRegistry::new(),
2772 #[cfg(feature = "ws")]
2773 channels: crate::channels::Channels::new(32),
2774 #[cfg(feature = "presence")]
2775 presence: crate::presence::Presence::new(crate::channels::Channels::new(32)),
2776 #[cfg(feature = "ws")]
2777 shutdown: tokio_util::sync::CancellationToken::new(),
2778 policy_registry: crate::authorization::PolicyRegistry::default(),
2779 forbidden_response: crate::authorization::ForbiddenResponse::default(),
2780 auth_session_key: "user_id".to_owned(),
2781 shared_cache: None,
2782 clock: std::sync::Arc::new(crate::time::SystemClock),
2783 }
2784 }
2785
2786 #[tokio::test]
2787 async fn build_router_mounts_actuator_at_configured_prefix() {
2788 let mut config = AutumnConfig::default();
2789 config.actuator.prefix = "/ops".to_owned();
2790 config.actuator.sensitive = true;
2791
2792 let app = build_router(Vec::new(), &config, test_state());
2793
2794 let prefixed = app
2795 .clone()
2796 .oneshot(
2797 Request::builder()
2798 .uri("/ops/health")
2799 .body(Body::empty())
2800 .unwrap(),
2801 )
2802 .await
2803 .unwrap();
2804 assert_eq!(prefixed.status(), StatusCode::OK);
2805
2806 let legacy = app
2807 .oneshot(
2808 Request::builder()
2809 .uri("/actuator/health")
2810 .body(Body::empty())
2811 .unwrap(),
2812 )
2813 .await
2814 .unwrap();
2815 assert_eq!(legacy.status(), StatusCode::NOT_FOUND);
2816 }
2817
2818 #[test]
2823 fn startup_barrier_503s_are_access_logged() {
2824 use tracing_subscriber::layer::SubscriberExt as _;
2825
2826 #[derive(Clone, Default)]
2827 struct Capture {
2828 events: Arc<std::sync::Mutex<Vec<std::collections::BTreeMap<String, String>>>>,
2829 }
2830 struct Visitor<'a>(&'a mut std::collections::BTreeMap<String, String>);
2831 impl tracing::field::Visit for Visitor<'_> {
2832 fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
2833 self.0.insert(field.name().to_owned(), format!("{value:?}"));
2834 }
2835 fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
2836 self.0.insert(field.name().to_owned(), value.to_string());
2837 }
2838 }
2839 impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for Capture {
2840 fn on_event(
2841 &self,
2842 event: &tracing::Event<'_>,
2843 _ctx: tracing_subscriber::layer::Context<'_, S>,
2844 ) {
2845 if event.metadata().target() != crate::middleware::ACCESS_LOG_TARGET {
2846 return;
2847 }
2848 let mut fields = std::collections::BTreeMap::new();
2849 event.record(&mut Visitor(&mut fields));
2850 self.events.lock().unwrap().push(fields);
2851 }
2852 }
2853
2854 let capture = Capture::default();
2855 let events = Arc::clone(&capture.events);
2856 let subscriber = tracing_subscriber::registry().with(capture);
2857
2858 tracing::subscriber::with_default(subscriber, || {
2859 let state = AppState::for_test()
2862 .with_profile("test")
2863 .with_startup_complete(false);
2864 let app = build_router(Vec::new(), &AutumnConfig::default(), state);
2865 let rt = tokio::runtime::Builder::new_current_thread()
2866 .enable_all()
2867 .build()
2868 .unwrap();
2869 let response = rt.block_on(async {
2870 app.oneshot(
2871 Request::builder()
2872 .uri("/not-a-probe")
2873 .body(Body::empty())
2874 .unwrap(),
2875 )
2876 .await
2877 .unwrap()
2878 });
2879 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
2880 });
2881
2882 let events = events.lock().unwrap().clone();
2883 assert_eq!(
2884 events.len(),
2885 1,
2886 "a barrier-rejected request should emit one access event: {events:?}"
2887 );
2888 assert_eq!(events[0].get("status").map(String::as_str), Some("503"));
2889 assert!(
2890 !events[0].contains_key("request_id"),
2891 "barrier short-circuits before RequestIdLayer, so no request id"
2892 );
2893 }
2894
2895 #[test]
2896 fn try_build_router_rejects_invalid_session_backend_config() {
2897 let mut config = AutumnConfig::default();
2898 config.session.backend = crate::session::SessionBackend::Redis;
2899
2900 let error = try_build_router(Vec::new(), &config, test_state())
2901 .expect_err("missing redis config should fail checked router build");
2902
2903 assert!(matches!(
2904 error,
2905 RouterBuildError::InvalidSessionBackend(
2906 crate::session::SessionBackendConfigError::MissingRedisUrl
2907 )
2908 ));
2909 }
2910
2911 #[test]
2912 fn try_build_router_with_static_rejects_invalid_session_backend_config() {
2913 let mut config = AutumnConfig::default();
2914 config.session.backend = crate::session::SessionBackend::Redis;
2915
2916 let error = try_build_router_with_static(Vec::new(), &config, test_state(), None)
2917 .expect_err("missing redis config should fail checked static router build");
2918
2919 assert!(matches!(
2920 error,
2921 RouterBuildError::InvalidSessionBackend(
2922 crate::session::SessionBackendConfigError::MissingRedisUrl
2923 )
2924 ));
2925 }
2926
2927 #[test]
2928 fn try_build_router_returns_error_for_probe_actuator_path_overlap() {
2929 let mut config = AutumnConfig::default();
2930 config.actuator.prefix = "/".to_owned();
2931
2932 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2933 try_build_router(Vec::new(), &config, test_state())
2934 }));
2935
2936 assert!(result.is_ok(), "try_build_router panicked on route overlap");
2937 assert!(
2938 result.unwrap().is_err(),
2939 "route overlap should be reported as a checked router build error"
2940 );
2941 }
2942
2943 #[tokio::test]
2944 async fn apply_cors_middleware_skipped_when_no_origins() {
2945 let config = AutumnConfig::default();
2946 assert!(config.cors.allowed_origins.is_empty());
2947
2948 let base: axum::Router<AppState> =
2949 axum::Router::new().route("/test", axum::routing::get(|| async { "ok" }));
2950 let router = apply_cors_middleware(base, &config).with_state(test_state());
2951
2952 let response = router
2953 .oneshot(
2954 Request::builder()
2955 .uri("/test")
2956 .header("Origin", "https://example.com")
2957 .body(Body::empty())
2958 .unwrap(),
2959 )
2960 .await
2961 .unwrap();
2962
2963 assert_eq!(response.status(), StatusCode::OK);
2964 assert!(
2965 response
2966 .headers()
2967 .get("access-control-allow-origin")
2968 .is_none(),
2969 "CORS header must be absent when no origins are configured"
2970 );
2971 }
2972
2973 #[tokio::test]
2974 async fn apply_cors_middleware_present_when_origins_configured() {
2975 let mut config = AutumnConfig::default();
2976 config.cors.allowed_origins = vec!["https://example.com".to_owned()];
2977
2978 let base: axum::Router<AppState> =
2979 axum::Router::new().route("/test", axum::routing::get(|| async { "ok" }));
2980 let router = apply_cors_middleware(base, &config).with_state(test_state());
2981
2982 let response = router
2983 .oneshot(
2984 Request::builder()
2985 .uri("/test")
2986 .header("Origin", "https://example.com")
2987 .body(Body::empty())
2988 .unwrap(),
2989 )
2990 .await
2991 .unwrap();
2992
2993 assert_eq!(response.status(), StatusCode::OK);
2994 assert!(
2995 response
2996 .headers()
2997 .get("access-control-allow-origin")
2998 .is_some(),
2999 "CORS header must be present when origins are configured"
3000 );
3001 }
3002
3003 #[tokio::test]
3004 async fn apply_cors_middleware_handles_preflight_request() {
3005 let mut config = AutumnConfig::default();
3006 config.cors.allowed_origins = vec!["https://example.com".to_owned()];
3007
3008 let base: axum::Router<AppState> =
3009 axum::Router::new().route("/api/widgets", axum::routing::post(|| async { "ok" }));
3010 let router = apply_cors_middleware(base, &config).with_state(test_state());
3011
3012 let response = router
3013 .oneshot(
3014 Request::builder()
3015 .method("OPTIONS")
3016 .uri("/api/widgets")
3017 .header("Origin", "https://example.com")
3018 .header("Access-Control-Request-Method", "POST")
3019 .header("Access-Control-Request-Headers", "Content-Type")
3020 .body(Body::empty())
3021 .unwrap(),
3022 )
3023 .await
3024 .unwrap();
3025
3026 let headers = response.headers();
3027 assert_eq!(
3028 headers
3029 .get("access-control-allow-origin")
3030 .and_then(|v| v.to_str().ok()),
3031 Some("https://example.com"),
3032 "preflight must echo the allowed origin"
3033 );
3034 assert!(
3035 headers.get("access-control-allow-methods").is_some(),
3036 "preflight must advertise allowed methods"
3037 );
3038 assert!(
3039 headers.get("access-control-allow-headers").is_some(),
3040 "preflight must advertise allowed headers"
3041 );
3042 assert!(
3043 headers.get("access-control-max-age").is_some(),
3044 "preflight must advertise max-age so browsers can cache it"
3045 );
3046 }
3047
3048 #[tokio::test]
3049 async fn apply_csrf_middleware_skipped_when_disabled() {
3050 let config = AutumnConfig::default();
3051 assert!(!config.security.csrf.enabled);
3052
3053 let base: axum::Router<AppState> =
3054 axum::Router::new().route("/form", axum::routing::post(|| async { "posted" }));
3055 let router = apply_csrf_middleware(base, &config, None).with_state(test_state());
3056
3057 let response = router
3059 .oneshot(
3060 Request::builder()
3061 .method("POST")
3062 .uri("/form")
3063 .body(Body::empty())
3064 .unwrap(),
3065 )
3066 .await
3067 .unwrap();
3068
3069 assert_eq!(response.status(), StatusCode::OK);
3070 }
3071
3072 #[tokio::test]
3073 async fn apply_rate_limit_middleware_skipped_when_disabled() {
3074 let config = AutumnConfig::default();
3075 assert!(!config.security.rate_limit.enabled);
3076
3077 let base: axum::Router<AppState> =
3078 axum::Router::new().route("/ping", axum::routing::get(|| async { "pong" }));
3079 let state = test_state();
3080 let router = apply_rate_limit_middleware(base, &config, &state).with_state(state.clone());
3081
3082 for _ in 0..5 {
3084 let response = router
3085 .clone()
3086 .oneshot(Request::builder().uri("/ping").body(Body::empty()).unwrap())
3087 .await
3088 .unwrap();
3089 assert_eq!(response.status(), StatusCode::OK);
3090 }
3091 }
3092
3093 #[tokio::test]
3094 async fn apply_rate_limit_middleware_returns_429_when_exhausted() {
3095 let mut config = AutumnConfig::default();
3096 config.security.rate_limit.enabled = true;
3097 config.security.rate_limit.requests_per_second = 0.1;
3098 config.security.rate_limit.burst = 1;
3099 config.security.rate_limit.trust_forwarded_headers = true;
3100
3101 let base: axum::Router<AppState> =
3102 axum::Router::new().route("/ping", axum::routing::get(|| async { "pong" }));
3103 let state = test_state();
3104 let router = apply_rate_limit_middleware(base, &config, &state).with_state(state.clone());
3105
3106 let ok = router
3107 .clone()
3108 .oneshot(
3109 Request::builder()
3110 .uri("/ping")
3111 .header("X-Forwarded-For", "203.0.113.9")
3112 .body(Body::empty())
3113 .unwrap(),
3114 )
3115 .await
3116 .unwrap();
3117 assert_eq!(ok.status(), StatusCode::OK);
3118
3119 let blocked = router
3120 .oneshot(
3121 Request::builder()
3122 .uri("/ping")
3123 .header("X-Forwarded-For", "203.0.113.9")
3124 .body(Body::empty())
3125 .unwrap(),
3126 )
3127 .await
3128 .unwrap();
3129 assert_eq!(blocked.status(), StatusCode::TOO_MANY_REQUESTS);
3130 assert!(blocked.headers().get("retry-after").is_some());
3131 }
3132
3133 #[cfg(feature = "mcp")]
3134 #[tokio::test]
3135 async fn mcp_envelope_is_gated_during_maintenance() {
3136 use crate::maintenance::{MaintenanceConfig, MaintenanceState};
3137
3138 let mut config = AutumnConfig::default();
3141 config.security.trusted_hosts.hosts = vec!["app.example".to_owned()];
3142
3143 let wiring = crate::mcp::McpWiring {
3144 cors: crate::config::CorsConfig::default(),
3145 trusted_hosts: TrustedHostPolicy::from_config(&config),
3146 tenant_header: None,
3147 csrf_header: "x-csrf-token".to_owned(),
3148 envelope_rate_limited: false,
3149 };
3150 let mcp_router =
3151 crate::mcp::build_mcp_router("/mcp", Vec::new(), axum::Router::new(), wiring, None);
3152
3153 let initialize = || {
3154 Request::builder()
3155 .method("POST")
3156 .uri("/mcp")
3157 .header("host", "app.example")
3158 .header("content-type", "application/json")
3159 .body(Body::from(
3160 serde_json::json!({"jsonrpc":"2.0","id":1,"method":"initialize"}).to_string(),
3161 ))
3162 .unwrap()
3163 };
3164
3165 let state = test_state();
3168 let maintenance = MaintenanceState::new();
3169 maintenance.enable(MaintenanceConfig::default());
3170 state.insert_extension(maintenance);
3171 let gated = mcp_router
3172 .clone()
3173 .layer(build_maintenance_layer(&config, &state))
3174 .with_state(state);
3175 let resp = gated.oneshot(initialize()).await.unwrap();
3176 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
3177
3178 let state = test_state();
3181 let open = mcp_router
3182 .layer(build_maintenance_layer(&config, &state))
3183 .with_state(state);
3184 let resp = open.oneshot(initialize()).await.unwrap();
3185 assert_eq!(resp.status(), StatusCode::OK);
3186 }
3187
3188 #[cfg(feature = "mail")]
3189 fn dev_mail_preview_config(dir: &std::path::Path) -> AutumnConfig {
3190 let mut config = AutumnConfig {
3191 profile: Some("dev".to_owned()),
3192 mail: crate::mail::MailConfig {
3193 transport: crate::mail::Transport::File,
3194 file_dir: dir.to_path_buf(),
3195 ..Default::default()
3196 },
3197 ..Default::default()
3198 };
3199 config.security.trusted_hosts.hosts = vec!["example.com".to_owned()];
3200 config
3201 }
3202
3203 #[cfg(feature = "mail")]
3204 async fn response_text(response: axum::response::Response) -> String {
3205 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3206 .await
3207 .expect("body should collect");
3208 String::from_utf8(body.to_vec()).expect("body should be utf8")
3209 }
3210
3211 #[cfg(feature = "mail")]
3212 #[tokio::test]
3213 async fn build_router_mounts_dev_mail_preview_empty_state_for_file_transport() {
3214 let dir = tempfile::tempdir().expect("tempdir");
3215 let config = dev_mail_preview_config(dir.path());
3216 let router = build_router(Vec::new(), &config, test_state());
3217
3218 let response = router
3219 .oneshot(
3220 Request::builder()
3221 .uri("/_autumn/mail")
3222 .header("host", "example.com")
3223 .body(Body::empty())
3224 .unwrap(),
3225 )
3226 .await
3227 .unwrap();
3228
3229 assert_eq!(response.status(), StatusCode::OK);
3230 let body = response_text(response).await;
3231 assert!(
3232 body.contains("No captured emails"),
3233 "missing empty state: {body}"
3234 );
3235 assert!(
3236 body.contains("mail.transport = "file""),
3237 "empty state should explain capture setup: {body}"
3238 );
3239 }
3240
3241 #[cfg(feature = "mail")]
3242 #[tokio::test]
3243 async fn build_router_lists_captured_mail_newest_first() {
3244 let dir = tempfile::tempdir().expect("tempdir");
3245 let older = dir.path().join("older.eml");
3246 let newer = dir.path().join("newer.eml");
3247 std::fs::write(
3248 &older,
3249 "To: first@example.com\nSubject: First\nDate: Tue, 05 May 2026 10:00:00 +0000\nMessage-Id: <first@example.com>\n\nfirst body\n",
3250 )
3251 .expect("write older eml");
3252 std::fs::write(
3253 &newer,
3254 "To: second@example.com\nSubject: Second\nDate: Tue, 05 May 2026 10:01:00 +0000\nMessage-Id: <second@example.com>\n\nsecond body\n",
3255 )
3256 .expect("write newer eml");
3257 filetime::set_file_mtime(&older, filetime::FileTime::from_unix_time(100, 0))
3258 .expect("set older mtime");
3259 filetime::set_file_mtime(&newer, filetime::FileTime::from_unix_time(200, 0))
3260 .expect("set newer mtime");
3261
3262 let config = dev_mail_preview_config(dir.path());
3263 let router = build_router(Vec::new(), &config, test_state());
3264 let response = router
3265 .oneshot(
3266 Request::builder()
3267 .uri("/_autumn/mail")
3268 .header("host", "example.com")
3269 .body(Body::empty())
3270 .unwrap(),
3271 )
3272 .await
3273 .unwrap();
3274
3275 assert_eq!(response.status(), StatusCode::OK);
3276 let body = response_text(response).await;
3277 let second = body.find("Second").expect("newer subject should render");
3278 let first = body.find("First").expect("older subject should render");
3279 assert!(second < first, "newest message should render first: {body}");
3280 assert!(
3281 body.contains("second@example.com"),
3282 "missing To column: {body}"
3283 );
3284 assert!(
3285 body.contains("Timestamp"),
3286 "missing timestamp column: {body}"
3287 );
3288 }
3289
3290 #[cfg(feature = "mail")]
3291 #[tokio::test]
3292 async fn build_router_mail_preview_detail_renders_html_in_sandboxed_iframe() {
3293 let dir = tempfile::tempdir().expect("tempdir");
3294 std::fs::write(
3295 dir.path().join("detail.eml"),
3296 "From: Autumn <noreply@example.com>\nTo: ada@example.com\nReply-To: support@example.com\nSubject: Reset\nDate: Tue, 05 May 2026 10:00:00 +0000\nMessage-Id: <reset@example.com>\nMIME-Version: 1.0\nContent-Type: multipart/alternative; boundary=\"autumn-mail\"\n\n--autumn-mail\nContent-Type: text/plain; charset=utf-8\n\nPlain reset\n--autumn-mail\nContent-Type: text/html; charset=utf-8\n\n<h1>Hello iframe</h1>\n--autumn-mail--\n",
3297 )
3298 .expect("write detail eml");
3299
3300 let config = dev_mail_preview_config(dir.path());
3301 let router = build_router(Vec::new(), &config, test_state());
3302 let response = router
3303 .oneshot(
3304 Request::builder()
3305 .uri("/_autumn/mail/messages/detail.eml")
3306 .header("host", "example.com")
3307 .body(Body::empty())
3308 .unwrap(),
3309 )
3310 .await
3311 .unwrap();
3312
3313 assert_eq!(response.status(), StatusCode::OK);
3314 let body = response_text(response).await;
3315 assert!(body.contains("<iframe"), "missing iframe: {body}");
3316 assert!(body.contains("sandbox"), "iframe must be sandboxed: {body}");
3317 assert!(body.contains("Hello iframe"), "missing html body: {body}");
3318 assert!(body.contains("Plain text"), "missing text toggle: {body}");
3319 assert!(body.contains("Headers"), "missing headers toggle: {body}");
3320 assert!(
3321 body.contains("Raw .eml"),
3322 "missing raw source toggle: {body}"
3323 );
3324 assert!(
3325 body.contains("Message-Id"),
3326 "missing message id header: {body}"
3327 );
3328 }
3329
3330 #[cfg(feature = "mail")]
3331 #[tokio::test]
3332 async fn build_router_does_not_mount_mail_preview_outside_dev() {
3333 let dir = tempfile::tempdir().expect("tempdir");
3334 let mut config = dev_mail_preview_config(dir.path());
3335 config.profile = Some("prod".to_owned());
3336 let router = build_router(Vec::new(), &config, test_state());
3337
3338 let response = router
3339 .oneshot(
3340 Request::builder()
3341 .uri("/_autumn/mail")
3342 .header("host", "example.com")
3343 .body(Body::empty())
3344 .unwrap(),
3345 )
3346 .await
3347 .unwrap();
3348
3349 assert_eq!(response.status(), StatusCode::NOT_FOUND);
3350 }
3351
3352 #[tokio::test]
3353 async fn apply_csrf_middleware_blocks_without_token_when_enabled() {
3354 let mut config = AutumnConfig::default();
3355 config.security.csrf.enabled = true;
3356
3357 let base: axum::Router<AppState> =
3358 axum::Router::new().route("/form", axum::routing::post(|| async { "posted" }));
3359 let router = apply_csrf_middleware(base, &config, None).with_state(test_state());
3360
3361 let response = router
3363 .oneshot(
3364 Request::builder()
3365 .method("POST")
3366 .uri("/form")
3367 .body(Body::empty())
3368 .unwrap(),
3369 )
3370 .await
3371 .unwrap();
3372
3373 assert_ne!(
3374 response.status(),
3375 StatusCode::OK,
3376 "POST without CSRF token should be rejected when CSRF is enabled"
3377 );
3378 }
3379
3380 #[test]
3381 fn join_nested_path_normalizes_like_axum() {
3382 assert_eq!(super::join_nested_path("/api", "/"), "/api");
3387 assert_eq!(super::join_nested_path("/api/", "/"), "/api");
3389 assert_eq!(super::join_nested_path("/api", "/users"), "/api/users");
3391 assert_eq!(super::join_nested_path("/api/", "/users"), "/api/users");
3394 assert_eq!(super::join_nested_path("", "/"), "/");
3396 assert_eq!(super::join_nested_path("", "/users"), "/users");
3397 }
3398
3399 #[cfg(feature = "openapi")]
3400 #[tokio::test]
3401 async fn try_build_router_detects_scoped_root_collision() {
3402 use crate::openapi::{ApiDoc, OpenApiConfig};
3406 async fn child() -> &'static str {
3407 "inner"
3408 }
3409 let group = crate::app::ScopedGroup {
3410 prefix: "/api".to_owned(),
3411 routes: vec![Route {
3412 method: http::Method::GET,
3413 path: "/",
3414 handler: axum::routing::get(child),
3415 name: "root",
3416 api_doc: ApiDoc {
3417 method: "GET",
3418 path: "/",
3419 operation_id: "root",
3420 success_status: 200,
3421 ..Default::default()
3422 },
3423 repository: None,
3424 idempotency: crate::route::RouteIdempotency::Direct,
3425 api_version: None,
3426 sunset_opt_out: false,
3427 }],
3428 source: crate::route_listing::RouteSource::User,
3429 apply_layer: Box::new(|r| r),
3430 };
3431
3432 let openapi = OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/api");
3433 let config = AutumnConfig::default();
3434 let ctx = RouterContext {
3435 exception_filters: Vec::new(),
3436 scoped_groups: vec![group],
3437 merge_routers: Vec::new(),
3438 nest_routers: Vec::new(),
3439 custom_layers: Vec::new(),
3440 error_page_renderer: None,
3441 session_store: None,
3442 openapi: Some(openapi),
3443 #[cfg(feature = "mcp")]
3444 mcp: None,
3445 };
3446 let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
3447 .expect_err("scope '/api' + child '/' should collide with openapi path '/api'");
3448 assert!(matches!(
3449 err,
3450 RouterBuildError::OpenApiPathCollision {
3451 field: "openapi_json_path",
3452 ..
3453 }
3454 ));
3455 }
3456
3457 #[cfg(feature = "openapi")]
3458 #[test]
3459 fn extract_path_params_matches_macro_behavior() {
3460 assert_eq!(
3461 super::extract_path_params("/orgs/{org_id}/users/{id}"),
3462 vec!["org_id".to_owned(), "id".to_owned()]
3463 );
3464 assert!(super::extract_path_params("/static").is_empty());
3465 assert_eq!(
3466 super::extract_path_params("/users/{id:[0-9]+}"),
3467 vec!["id".to_owned()]
3468 );
3469 }
3470
3471 #[cfg(feature = "openapi")]
3472 #[tokio::test]
3473 async fn openapi_merges_scoped_prefix_path_params() {
3474 use crate::openapi::{ApiDoc, OpenApiConfig};
3475
3476 async fn handler() -> &'static str {
3481 "ok"
3482 }
3483 let child = Route {
3484 method: http::Method::GET,
3485 path: "/users/{id}",
3486 handler: axum::routing::get(handler),
3487 name: "child",
3488 api_doc: ApiDoc {
3489 method: "GET",
3490 path: "/users/{id}",
3491 operation_id: "child",
3492 path_params: &["id"],
3493 success_status: 200,
3494 ..Default::default()
3495 },
3496 repository: None,
3497 idempotency: crate::route::RouteIdempotency::Direct,
3498 api_version: None,
3499 sunset_opt_out: false,
3500 };
3501 let group = crate::app::ScopedGroup {
3502 prefix: "/orgs/{org_id}".to_owned(),
3503 routes: vec![child],
3504 source: crate::route_listing::RouteSource::User,
3505 apply_layer: Box::new(|r| r),
3506 };
3507
3508 let config = OpenApiConfig::new("Demo", "1.0.0");
3509 let router = super::build_openapi_router(&[], &[group], Some(&config), "autumn.sid", &[])
3510 .expect("openapi sub-router builds")
3511 .expect("openapi sub-router present when config is Some");
3512 let state = test_state();
3513 let router = router.with_state(state);
3514
3515 let response = router
3516 .oneshot(
3517 Request::builder()
3518 .uri("/openapi.json")
3519 .body(Body::empty())
3520 .unwrap(),
3521 )
3522 .await
3523 .unwrap();
3524 assert_eq!(response.status(), StatusCode::OK);
3525 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3526 .await
3527 .unwrap();
3528 let spec: serde_json::Value = serde_json::from_slice(&body).unwrap();
3529 let params = &spec["paths"]["/orgs/{org_id}/users/{id}"]["get"]["parameters"];
3530 let names: Vec<&str> = params
3531 .as_array()
3532 .unwrap()
3533 .iter()
3534 .map(|p| p["name"].as_str().unwrap())
3535 .collect();
3536 assert!(names.contains(&"org_id"), "missing org_id: {names:?}");
3537 assert!(names.contains(&"id"), "missing id: {names:?}");
3538 }
3539
3540 #[cfg(feature = "openapi")]
3541 #[tokio::test]
3542 async fn openapi_documents_configured_session_cookie_name() {
3543 use crate::openapi::{ApiDoc, OpenApiConfig};
3544
3545 async fn handler() -> &'static str {
3546 "ok"
3547 }
3548
3549 let route = Route {
3550 method: http::Method::GET,
3551 path: "/protected",
3552 handler: axum::routing::get(handler),
3553 name: "protected",
3554 api_doc: ApiDoc {
3555 method: "GET",
3556 path: "/protected",
3557 operation_id: "protected",
3558 success_status: 200,
3559 secured: true,
3560 ..Default::default()
3561 },
3562 repository: None,
3563 idempotency: crate::route::RouteIdempotency::Direct,
3564 api_version: None,
3565 sunset_opt_out: false,
3566 };
3567
3568 let protected_routes = vec![route];
3569 let config = OpenApiConfig::new("Demo", "1.0.0");
3570 let docs_router =
3571 super::build_openapi_router(&protected_routes, &[], Some(&config), "demo.sid", &[])
3572 .expect("openapi sub-router builds")
3573 .expect("openapi sub-router present when config is Some");
3574 let docs_router = docs_router.with_state(test_state());
3575
3576 let response = docs_router
3577 .oneshot(
3578 Request::builder()
3579 .uri("/openapi.json")
3580 .body(Body::empty())
3581 .unwrap(),
3582 )
3583 .await
3584 .unwrap();
3585 assert_eq!(response.status(), StatusCode::OK);
3586 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3587 .await
3588 .unwrap();
3589 let spec: serde_json::Value = serde_json::from_slice(&body).unwrap();
3590 let schemes = &spec["components"]["securitySchemes"];
3591
3592 assert_eq!(schemes["SessionAuth"]["type"], "apiKey");
3593 assert_eq!(schemes["SessionAuth"]["in"], "cookie");
3594 assert_eq!(schemes["SessionAuth"]["name"], "demo.sid");
3595 assert!(
3596 schemes.get("BearerAuth").is_none(),
3597 "secured routes must not be documented as bearer JWT routes"
3598 );
3599 }
3600
3601 #[cfg(feature = "openapi")]
3602 #[test]
3603 fn openapi_rejects_json_path_without_leading_slash() {
3604 let config =
3605 crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("openapi.json");
3606 let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
3607 .expect_err("non-slash path should be rejected");
3608 assert!(matches!(
3609 err,
3610 RouterBuildError::InvalidOpenApiPath {
3611 field: "openapi_json_path",
3612 ..
3613 }
3614 ));
3615 }
3616
3617 #[cfg(feature = "openapi")]
3618 #[test]
3619 fn openapi_rejects_path_with_captures() {
3620 let config =
3623 crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/docs/{id}");
3624 let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
3625 .expect_err("captures should be rejected");
3626 assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
3627 }
3628
3629 #[cfg(feature = "openapi")]
3630 #[test]
3631 fn openapi_rejects_path_with_unbalanced_brace() {
3632 let config =
3633 crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/docs/{id");
3634 let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
3635 .expect_err("unbalanced brace should be rejected");
3636 assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
3637 }
3638
3639 #[cfg(feature = "openapi")]
3640 #[test]
3641 fn openapi_rejects_path_with_wildcard() {
3642 let config =
3643 crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/docs/*rest");
3644 let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
3645 .expect_err("wildcard should be rejected");
3646 assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
3647 }
3648
3649 #[cfg(feature = "openapi")]
3650 #[test]
3651 fn openapi_rejects_path_with_double_slash() {
3652 let config =
3653 crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("//docs");
3654 let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
3655 .expect_err("double-slash should be rejected");
3656 assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
3657 }
3658
3659 #[cfg(feature = "openapi")]
3660 #[test]
3661 fn openapi_rejects_swagger_ui_path_without_leading_slash() {
3662 let config = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
3663 .swagger_ui_path(Some("docs".to_owned()));
3664 let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
3665 .expect_err("non-slash path should be rejected");
3666 assert!(matches!(
3667 err,
3668 RouterBuildError::InvalidOpenApiPath {
3669 field: "swagger_ui_path",
3670 ..
3671 }
3672 ));
3673 }
3674
3675 #[cfg(feature = "openapi")]
3676 #[test]
3677 fn openapi_rejects_empty_json_path() {
3678 let config = crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("");
3679 let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
3680 .expect_err("empty path should be rejected");
3681 assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
3682 }
3683
3684 #[cfg(feature = "openapi")]
3685 #[test]
3686 fn openapi_accepts_valid_paths() {
3687 let config = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
3688 .openapi_json_path("/api-docs")
3689 .swagger_ui_path(Some("/ui".to_owned()));
3690 let out = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
3691 .expect("valid paths must not error");
3692 assert!(out.is_some());
3693 }
3694
3695 #[cfg(feature = "openapi")]
3696 #[test]
3697 fn openapi_rejects_duplicate_json_and_swagger_paths() {
3698 let config = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
3699 .openapi_json_path("/docs")
3700 .swagger_ui_path(Some("/docs".to_owned()));
3701 let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
3702 .expect_err("colliding paths should be rejected before axum panics");
3703 assert!(matches!(
3704 err,
3705 RouterBuildError::DuplicateOpenApiPath { ref path } if path == "/docs"
3706 ));
3707 }
3708
3709 #[cfg(feature = "openapi")]
3710 async fn collision_test_handler() -> &'static str {
3711 "user"
3712 }
3713
3714 #[cfg(feature = "openapi")]
3715 #[tokio::test]
3716 async fn try_build_router_rejects_openapi_path_colliding_with_user_route() {
3717 let mut config = AutumnConfig::default();
3718 config.actuator.prefix = "/ops".to_owned();
3719 let openapi =
3720 crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/my-api-docs");
3721
3722 let user_route = Route {
3723 method: http::Method::GET,
3724 path: "/my-api-docs",
3725 handler: axum::routing::get(collision_test_handler),
3726 name: "collides",
3727 api_doc: crate::openapi::ApiDoc {
3728 method: "GET",
3729 path: "/my-api-docs",
3730 operation_id: "collides",
3731 success_status: 200,
3732 ..Default::default()
3733 },
3734 repository: None,
3735 idempotency: crate::route::RouteIdempotency::Direct,
3736 api_version: None,
3737 sunset_opt_out: false,
3738 };
3739
3740 let ctx = RouterContext {
3741 exception_filters: Vec::new(),
3742 scoped_groups: Vec::new(),
3743 merge_routers: Vec::new(),
3744 nest_routers: Vec::new(),
3745 custom_layers: Vec::new(),
3746 error_page_renderer: None,
3747 session_store: None,
3748 openapi: Some(openapi),
3749 #[cfg(feature = "mcp")]
3750 mcp: None,
3751 };
3752 let err = super::try_build_router_inner(vec![user_route], &config, test_state(), ctx)
3753 .expect_err("user-owned path should prevent OpenAPI mount");
3754 assert!(matches!(
3755 err,
3756 RouterBuildError::OpenApiPathCollision { field: "openapi_json_path", ref path } if path == "/my-api-docs"
3757 ));
3758 }
3759
3760 #[cfg(feature = "openapi")]
3761 #[tokio::test]
3762 async fn try_build_router_rejects_openapi_path_colliding_with_framework_route() {
3763 let config = AutumnConfig::default(); let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
3765 .openapi_json_path("/actuator/health");
3766 let ctx = RouterContext {
3767 exception_filters: Vec::new(),
3768 scoped_groups: Vec::new(),
3769 merge_routers: Vec::new(),
3770 nest_routers: Vec::new(),
3771 custom_layers: Vec::new(),
3772 error_page_renderer: None,
3773 session_store: None,
3774 openapi: Some(openapi),
3775 #[cfg(feature = "mcp")]
3776 mcp: None,
3777 };
3778 let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
3779 .expect_err("framework-owned path should prevent OpenAPI mount");
3780 assert!(matches!(
3781 err,
3782 RouterBuildError::OpenApiPathCollision {
3783 field: "openapi_json_path",
3784 ..
3785 }
3786 ));
3787 }
3788
3789 #[cfg(feature = "openapi")]
3790 #[tokio::test]
3791 async fn try_build_router_rejects_swagger_ui_asset_path_colliding_with_user_route() {
3792 let config = AutumnConfig::default();
3793 let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0");
3794
3795 let user_route = Route {
3796 method: http::Method::GET,
3797 path: "/swagger-ui/swagger-ui.css",
3798 handler: axum::routing::get(collision_test_handler),
3799 name: "swagger-ui-asset-collides",
3800 api_doc: crate::openapi::ApiDoc {
3801 method: "GET",
3802 path: "/swagger-ui/swagger-ui.css",
3803 operation_id: "swagger_ui_asset_collides",
3804 success_status: 200,
3805 ..Default::default()
3806 },
3807 repository: None,
3808 idempotency: crate::route::RouteIdempotency::Direct,
3809 api_version: None,
3810 sunset_opt_out: false,
3811 };
3812
3813 let ctx = RouterContext {
3814 exception_filters: Vec::new(),
3815 scoped_groups: Vec::new(),
3816 merge_routers: Vec::new(),
3817 nest_routers: Vec::new(),
3818 custom_layers: Vec::new(),
3819 error_page_renderer: None,
3820 session_store: None,
3821 openapi: Some(openapi),
3822 #[cfg(feature = "mcp")]
3823 mcp: None,
3824 };
3825 let err = super::try_build_router_inner(vec![user_route], &config, test_state(), ctx)
3826 .expect_err("swagger ui asset path should be reserved");
3827 assert!(matches!(
3828 err,
3829 RouterBuildError::OpenApiPathCollision {
3830 field: "swagger_ui_path",
3831 ref path,
3832 } if path == "/swagger-ui/swagger-ui.css"
3833 ));
3834 }
3835
3836 #[cfg(all(feature = "openapi", feature = "htmx"))]
3837 #[tokio::test]
3838 async fn try_build_router_rejects_openapi_path_colliding_with_htmx_csrf_route() {
3839 let config = AutumnConfig::default();
3840 let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
3841 .openapi_json_path(crate::htmx::HTMX_CSRF_JS_PATH);
3842 let ctx = RouterContext {
3843 exception_filters: Vec::new(),
3844 scoped_groups: Vec::new(),
3845 merge_routers: Vec::new(),
3846 nest_routers: Vec::new(),
3847 custom_layers: Vec::new(),
3848 error_page_renderer: None,
3849 session_store: None,
3850 openapi: Some(openapi),
3851 #[cfg(feature = "mcp")]
3852 mcp: None,
3853 };
3854 let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
3855 .expect_err("htmx csrf helper path should be reserved");
3856 assert!(matches!(
3857 err,
3858 RouterBuildError::OpenApiPathCollision {
3859 field: "openapi_json_path",
3860 ref path,
3861 } if path == crate::htmx::HTMX_CSRF_JS_PATH
3862 ));
3863 }
3864
3865 #[cfg(feature = "openapi")]
3866 #[tokio::test]
3867 async fn try_build_router_rejects_openapi_path_under_nest_prefix() {
3868 let config = AutumnConfig::default();
3873 let openapi =
3874 crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/api/docs");
3875 let nested = axum::Router::<AppState>::new()
3876 .route("/inner", axum::routing::get(|| async { "inner" }));
3877 let ctx = RouterContext {
3878 exception_filters: Vec::new(),
3879 scoped_groups: Vec::new(),
3880 merge_routers: Vec::new(),
3881 nest_routers: vec![("/api".to_owned(), nested)],
3882 custom_layers: Vec::new(),
3883 error_page_renderer: None,
3884 session_store: None,
3885 openapi: Some(openapi),
3886 #[cfg(feature = "mcp")]
3887 mcp: None,
3888 };
3889 let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
3890 .expect_err("OpenAPI path under a nest prefix should collide");
3891 assert!(matches!(
3892 err,
3893 RouterBuildError::OpenApiPathCollision {
3894 field: "openapi_json_path",
3895 ref path,
3896 } if path == "/api/docs"
3897 ));
3898 }
3899
3900 #[cfg(feature = "openapi")]
3901 #[test]
3902 fn try_build_router_rejects_openapi_path_on_dev_live_reload() {
3903 temp_env::with_vars(
3904 [
3905 ("AUTUMN_DEV_RELOAD", Some("1")),
3906 ("AUTUMN_DEV_RELOAD_STATE", Some("/tmp/autumn-reload-test")),
3907 ],
3908 || {
3909 let config = AutumnConfig::default();
3910 let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
3911 .openapi_json_path("/__autumn/live-reload");
3912 let ctx = RouterContext {
3913 exception_filters: Vec::new(),
3914 scoped_groups: Vec::new(),
3915 merge_routers: Vec::new(),
3916 nest_routers: Vec::new(),
3917 custom_layers: Vec::new(),
3918 error_page_renderer: None,
3919 session_store: None,
3920 openapi: Some(openapi),
3921 #[cfg(feature = "mcp")]
3922 mcp: None,
3923 };
3924 let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
3925 .expect_err("dev reload path should be reserved");
3926 assert!(matches!(
3927 err,
3928 RouterBuildError::OpenApiPathCollision {
3929 field: "openapi_json_path",
3930 ..
3931 }
3932 ));
3933 },
3934 );
3935 }
3936
3937 fn create_static_dist(revalidate: Option<u64>) -> tempfile::TempDir {
3940 let dir = tempfile::tempdir().expect("tempdir");
3941 let dist = dir.path().join("dist");
3942 std::fs::create_dir_all(dist.join("about")).expect("mkdir about");
3943 std::fs::write(dist.join("index.html"), b"<h1>Home</h1>").expect("write index");
3944 std::fs::write(dist.join("about/index.html"), b"<h1>About</h1>").expect("write about");
3945
3946 let mut routes = std::collections::HashMap::new();
3947 routes.insert(
3948 "/".to_owned(),
3949 crate::static_gen::ManifestEntry {
3950 file: "index.html".to_owned(),
3951 revalidate: None,
3952 },
3953 );
3954 routes.insert(
3955 "/about".to_owned(),
3956 crate::static_gen::ManifestEntry {
3957 file: "about/index.html".to_owned(),
3958 revalidate,
3959 },
3960 );
3961
3962 let manifest = crate::static_gen::StaticManifest {
3963 generated_at: "2026-05-18T00:00:00Z".to_owned(),
3964 autumn_version: "0.5.0".to_owned(),
3965 routes,
3966 };
3967 let json = serde_json::to_string(&manifest).expect("serialize manifest");
3968 std::fs::write(dist.join("manifest.json"), json).expect("write manifest");
3969 dir
3970 }
3971
3972 #[tokio::test]
3973 async fn static_serving_serves_get_request_inside_user_layers() {
3974 let tmp = create_static_dist(None);
3975 let dist = tmp.path().join("dist");
3976 let config = AutumnConfig::default();
3977
3978 let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
3979 .expect("router builds");
3980
3981 let response = router
3982 .oneshot(
3983 Request::builder()
3984 .uri("/about")
3985 .body(Body::empty())
3986 .unwrap(),
3987 )
3988 .await
3989 .unwrap();
3990
3991 assert_eq!(response.status(), StatusCode::OK);
3992 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3993 .await
3994 .unwrap();
3995 assert_eq!(body.as_ref(), b"<h1>About</h1>");
3996 }
3997
3998 #[tokio::test]
3999 async fn static_serving_serves_head_request() {
4000 let tmp = create_static_dist(None);
4001 let dist = tmp.path().join("dist");
4002 let config = AutumnConfig::default();
4003
4004 let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
4005 .expect("router builds");
4006
4007 let response = router
4008 .oneshot(
4009 Request::builder()
4010 .method("HEAD")
4011 .uri("/about")
4012 .body(Body::empty())
4013 .unwrap(),
4014 )
4015 .await
4016 .unwrap();
4017
4018 assert_eq!(response.status(), StatusCode::OK);
4019 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
4020 .await
4021 .unwrap();
4022 assert!(body.is_empty(), "HEAD response body should be empty");
4023 }
4024
4025 #[tokio::test]
4026 async fn static_serving_normalizes_trailing_slash() {
4027 let tmp = create_static_dist(None);
4028 let dist = tmp.path().join("dist");
4029 let config = AutumnConfig::default();
4030
4031 let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
4032 .expect("router builds");
4033
4034 let response = router
4035 .oneshot(
4036 Request::builder()
4037 .uri("/about/")
4038 .body(Body::empty())
4039 .unwrap(),
4040 )
4041 .await
4042 .unwrap();
4043
4044 assert_eq!(response.status(), StatusCode::OK);
4045 }
4046
4047 #[tokio::test]
4048 async fn static_serving_falls_through_for_unknown_route() {
4049 let tmp = create_static_dist(None);
4050 let dist = tmp.path().join("dist");
4051 let config = AutumnConfig::default();
4052
4053 let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
4054 .expect("router builds");
4055
4056 let response = router
4057 .oneshot(
4058 Request::builder()
4059 .uri("/not-in-manifest")
4060 .body(Body::empty())
4061 .unwrap(),
4062 )
4063 .await
4064 .unwrap();
4065
4066 assert_eq!(response.status(), StatusCode::NOT_FOUND);
4067 }
4068
4069 #[tokio::test]
4070 async fn static_serving_skipped_when_no_manifest() {
4071 let tmp = tempfile::tempdir().expect("tempdir");
4072 let dist = tmp.path().join("dist");
4073 std::fs::create_dir_all(&dist).expect("mkdir dist");
4074 let config = AutumnConfig::default();
4075
4076 let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
4077 .expect("router builds even without manifest");
4078
4079 let response = router
4080 .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
4081 .await
4082 .unwrap();
4083
4084 assert_eq!(response.status(), StatusCode::NOT_FOUND);
4085 }
4086
4087 #[tokio::test]
4088 async fn static_serving_with_isr_manifest_builds_successfully() {
4089 let tmp = create_static_dist(Some(3600));
4090 let dist = tmp.path().join("dist");
4091 let config = AutumnConfig::default();
4092
4093 let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
4094 .expect("router with ISR manifest should build");
4095
4096 let response = router
4097 .oneshot(
4098 Request::builder()
4099 .uri("/about")
4100 .body(Body::empty())
4101 .unwrap(),
4102 )
4103 .await
4104 .unwrap();
4105
4106 assert_eq!(response.status(), StatusCode::OK);
4107 }
4108}
4109
4110#[cfg(test)]
4111mod trusted_host_tests {
4112 use super::*;
4113 use axum::body::Body;
4114 use http::Request;
4115 use tower::util::ServiceExt;
4116
4117 #[tokio::test]
4118 async fn trusted_host_allows_matching_and_blocks_nonmatching() {
4119 let mut cfg = AutumnConfig::default();
4120 cfg.security.trusted_hosts.hosts = vec!["example.com".into(), ".example.com".into()];
4121 let state = crate::state::AppState::for_test();
4122 let router = build_router(vec![], &cfg, state);
4123
4124 let ok = router
4125 .clone()
4126 .oneshot(
4127 Request::builder()
4128 .uri("/nope")
4129 .header("host", "api.example.com")
4130 .body(Body::empty())
4131 .unwrap(),
4132 )
4133 .await
4134 .unwrap();
4135 assert_eq!(ok.status(), StatusCode::NOT_FOUND);
4136
4137 let blocked = router
4138 .oneshot(
4139 Request::builder()
4140 .uri("/nope")
4141 .header("host", "evil.com")
4142 .body(Body::empty())
4143 .unwrap(),
4144 )
4145 .await
4146 .unwrap();
4147 assert_eq!(blocked.status(), StatusCode::BAD_REQUEST);
4148 }
4149
4150 #[tokio::test]
4151 async fn trusted_host_wildcard_allows_any_host() {
4152 let mut cfg = AutumnConfig::default();
4153 cfg.security.trusted_hosts.hosts = vec!["*".into()];
4154 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
4155 let response = router
4156 .oneshot(
4157 Request::builder()
4158 .uri("/nope")
4159 .header("host", "anything.example")
4160 .body(Body::empty())
4161 .expect("request should build"),
4162 )
4163 .await
4164 .expect("request should complete");
4165 assert_eq!(response.status(), StatusCode::NOT_FOUND);
4166 }
4167
4168 #[tokio::test]
4169 async fn trusted_host_bypasses_probe_paths() {
4170 let mut cfg = AutumnConfig::default();
4171 cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
4172 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
4173 let response = router
4174 .oneshot(
4175 Request::builder()
4176 .uri("/actuator/health")
4177 .header("host", "evil.com")
4178 .body(Body::empty())
4179 .expect("request should build"),
4180 )
4181 .await
4182 .expect("request should complete");
4183 assert_eq!(response.status(), StatusCode::OK);
4184 }
4185
4186 #[tokio::test]
4187 async fn trusted_host_bypasses_actuator_health_path() {
4188 let mut cfg = AutumnConfig::default();
4189 cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
4190 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
4191 let response = router
4192 .oneshot(
4193 Request::builder()
4194 .uri("/actuator/health")
4195 .header("host", "evil.com")
4196 .body(Body::empty())
4197 .expect("request should build"),
4198 )
4199 .await
4200 .expect("request should complete");
4201 assert_eq!(response.status(), StatusCode::OK);
4202 }
4203
4204 #[tokio::test]
4205 async fn trusted_host_release_rejects_loopback_unless_listed() {
4206 let mut cfg = AutumnConfig {
4207 profile: Some("prod".into()),
4208 ..AutumnConfig::default()
4209 };
4210 cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
4211 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
4212 let response = router
4213 .oneshot(
4214 Request::builder()
4215 .uri("/nope")
4216 .header("host", "localhost")
4217 .body(Body::empty())
4218 .expect("request should build"),
4219 )
4220 .await
4221 .expect("request should complete");
4222 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
4223 }
4224
4225 #[tokio::test]
4226 async fn trusted_host_uses_uri_authority_when_host_header_missing() {
4227 let mut cfg = AutumnConfig::default();
4228 cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
4229 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
4230 let response = router
4231 .oneshot(
4232 Request::builder()
4233 .uri("http://EXAMPLE.COM/nope")
4234 .body(Body::empty())
4235 .expect("request should build"),
4236 )
4237 .await
4238 .expect("request should complete");
4239 assert_eq!(response.status(), StatusCode::NOT_FOUND);
4240 }
4241
4242 #[tokio::test]
4243 async fn trusted_host_accepts_bracketed_ipv6_loopback_in_dev() {
4244 let cfg = AutumnConfig::default();
4245 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
4246 let response = router
4247 .oneshot(
4248 Request::builder()
4249 .uri("/nope")
4250 .header("host", "[::1]:3000")
4251 .body(Body::empty())
4252 .expect("request should build"),
4253 )
4254 .await
4255 .expect("request should complete");
4256 assert_eq!(response.status(), StatusCode::NOT_FOUND);
4257 }
4258
4259 #[tokio::test]
4260 async fn trusted_host_matching_is_case_insensitive() {
4261 let mut cfg = AutumnConfig::default();
4262 cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
4263 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
4264 let response = router
4265 .oneshot(
4266 Request::builder()
4267 .uri("/nope")
4268 .header("host", "EXAMPLE.COM")
4269 .body(Body::empty())
4270 .expect("request should build"),
4271 )
4272 .await
4273 .expect("request should complete");
4274 assert_eq!(response.status(), StatusCode::NOT_FOUND);
4275 }
4276
4277 #[tokio::test]
4278 async fn trusted_host_rejects_malformed_port() {
4279 let mut cfg = AutumnConfig::default();
4280 cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
4281 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
4282 let response = router
4283 .oneshot(
4284 Request::builder()
4285 .uri("/nope")
4286 .header("host", "example.com:abc")
4287 .body(Body::empty())
4288 .expect("request should build"),
4289 )
4290 .await
4291 .expect("request should complete");
4292 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
4293 }
4294
4295 #[tokio::test]
4296 async fn trusted_host_rejects_empty_port_suffix() {
4297 let mut cfg = AutumnConfig::default();
4298 cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
4299 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
4300 let response = router
4301 .oneshot(
4302 Request::builder()
4303 .uri("/nope")
4304 .header("host", "example.com:")
4305 .body(Body::empty())
4306 .expect("request should build"),
4307 )
4308 .await
4309 .expect("request should complete");
4310 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
4311 }
4312
4313 #[tokio::test]
4314 async fn trusted_host_rejects_bracketed_reg_name() {
4315 let mut cfg = AutumnConfig::default();
4316 cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
4317 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
4318 let response = router
4319 .oneshot(
4320 Request::builder()
4321 .uri("/nope")
4322 .header("host", "[example.com]")
4323 .body(Body::empty())
4324 .expect("request should build"),
4325 )
4326 .await
4327 .expect("request should complete");
4328 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
4329 }
4330 #[tokio::test]
4331 async fn trusted_host_configured_trailing_dot_matches_normalized_host() {
4332 let mut cfg = AutumnConfig::default();
4333 cfg.security.trusted_hosts.hosts = vec!["example.com.".into()];
4334 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
4335 let response = router
4336 .oneshot(
4337 Request::builder()
4338 .uri("/nope")
4339 .header("host", "example.com")
4340 .body(Body::empty())
4341 .expect("request should build"),
4342 )
4343 .await
4344 .expect("request should complete");
4345 assert_eq!(response.status(), StatusCode::NOT_FOUND);
4346 }
4347
4348 #[tokio::test]
4349 async fn trusted_host_accepts_trailing_dot_fqdn() {
4350 let mut cfg = AutumnConfig::default();
4351 cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
4352 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
4353 let response = router
4354 .oneshot(
4355 Request::builder()
4356 .uri("/nope")
4357 .header("host", "example.com.")
4358 .body(Body::empty())
4359 .expect("request should build"),
4360 )
4361 .await
4362 .expect("request should complete");
4363 assert_eq!(response.status(), StatusCode::NOT_FOUND);
4364 }
4365
4366 #[tokio::test]
4367 async fn trusted_host_bypasses_custom_probe_path_only() {
4368 let mut cfg = AutumnConfig::default();
4369 cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
4370 cfg.health.path = "/healthz".into();
4371 cfg.health.startup_path = "/startupz".into();
4372 cfg.health.ready_path = "/readyz".into();
4373 cfg.health.live_path = "/livez".into();
4374 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
4375
4376 let bypassed = router
4377 .clone()
4378 .oneshot(
4379 Request::builder()
4380 .uri("/healthz")
4381 .header("host", "evil.com")
4382 .body(Body::empty())
4383 .expect("request should build"),
4384 )
4385 .await
4386 .expect("request should complete");
4387 assert_eq!(bypassed.status(), StatusCode::OK);
4388
4389 let not_bypassed = router
4390 .oneshot(
4391 Request::builder()
4392 .uri("/health")
4393 .header("host", "evil.com")
4394 .body(Body::empty())
4395 .expect("request should build"),
4396 )
4397 .await
4398 .expect("request should complete");
4399 assert_eq!(not_bypassed.status(), StatusCode::BAD_REQUEST);
4400 }
4401
4402 #[tokio::test]
4403 async fn trusted_host_does_not_bypass_non_get_probe_path_requests() {
4404 let mut cfg = AutumnConfig::default();
4405 cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
4406 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
4407 let response = router
4408 .oneshot(
4409 Request::builder()
4410 .method("POST")
4411 .uri("/health")
4412 .header("host", "evil.com")
4413 .body(Body::empty())
4414 .expect("request should build"),
4415 )
4416 .await
4417 .expect("request should complete");
4418 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
4419 }
4420
4421 #[tokio::test]
4424 async fn apply_upload_middleware_rejects_oversized_json_body() {
4425 let mut config = AutumnConfig::default();
4426 config.security.upload.max_request_size_bytes = 100; let base: axum::Router<AppState> = axum::Router::new().route(
4429 "/data",
4430 axum::routing::post(|_: axum::body::Bytes| async { "ok" }),
4431 );
4432 let router =
4433 apply_upload_middleware(base, &config).with_state(crate::state::AppState::for_test());
4434
4435 let big_body = "x".repeat(200);
4437 let response = router
4438 .oneshot(
4439 Request::builder()
4440 .method("POST")
4441 .uri("/data")
4442 .header("content-type", "application/json")
4443 .body(Body::from(big_body))
4444 .unwrap(),
4445 )
4446 .await
4447 .unwrap();
4448
4449 assert_eq!(
4450 response.status(),
4451 StatusCode::PAYLOAD_TOO_LARGE,
4452 "oversized body must be rejected with 413 regardless of content type"
4453 );
4454 }
4455
4456 #[tokio::test]
4457 async fn apply_upload_middleware_accepts_body_within_limit() {
4458 let mut config = AutumnConfig::default();
4459 config.security.upload.max_request_size_bytes = 1024;
4460
4461 let base: axum::Router<AppState> = axum::Router::new().route(
4462 "/data",
4463 axum::routing::post(|_: axum::body::Bytes| async { "ok" }),
4464 );
4465 let router =
4466 apply_upload_middleware(base, &config).with_state(crate::state::AppState::for_test());
4467
4468 let response = router
4469 .oneshot(
4470 Request::builder()
4471 .method("POST")
4472 .uri("/data")
4473 .header("content-type", "application/json")
4474 .body(Body::from("hello"))
4475 .unwrap(),
4476 )
4477 .await
4478 .unwrap();
4479
4480 assert_eq!(response.status(), StatusCode::OK);
4481 }
4482
4483 #[tokio::test(start_paused = true)]
4486 async fn request_timeout_returns_408_when_exceeded() {
4487 let mut config = AutumnConfig::default();
4488 config.server.timeouts.request_timeout_ms = Some(100);
4489
4490 let state = crate::state::AppState::for_test();
4491 let router: axum::Router<AppState> = axum::Router::new().route(
4492 "/slow",
4493 axum::routing::get(|| async {
4494 tokio::time::sleep(std::time::Duration::from_secs(60)).await;
4496 "ok"
4497 }),
4498 );
4499
4500 let router = apply_request_timeout_middleware(router, &config, state.metrics.clone())
4502 .layer(RequestIdLayer)
4503 .with_state(state);
4504
4505 let response = router
4506 .oneshot(Request::builder().uri("/slow").body(Body::empty()).unwrap())
4507 .await
4508 .unwrap();
4509
4510 assert_eq!(
4511 response.status(),
4512 StatusCode::REQUEST_TIMEOUT,
4513 "a slow handler must trigger 408"
4514 );
4515 assert_eq!(
4516 response
4517 .headers()
4518 .get("content-type")
4519 .and_then(|v| v.to_str().ok()),
4520 Some("application/problem+json"),
4521 "timeout response must use Problem Details content type"
4522 );
4523 }
4524
4525 #[tokio::test(start_paused = true)]
4526 async fn request_timeout_increments_metric() {
4527 let mut config = AutumnConfig::default();
4528 config.server.timeouts.request_timeout_ms = Some(100);
4529
4530 let state = crate::state::AppState::for_test();
4531 let router: axum::Router<AppState> = axum::Router::new().route(
4532 "/slow",
4533 axum::routing::get(|| async {
4534 tokio::time::sleep(std::time::Duration::from_secs(60)).await;
4535 "ok"
4536 }),
4537 );
4538
4539 let router = apply_request_timeout_middleware(router, &config, state.metrics.clone())
4540 .layer(RequestIdLayer)
4541 .with_state(state.clone());
4542
4543 router
4544 .oneshot(Request::builder().uri("/slow").body(Body::empty()).unwrap())
4545 .await
4546 .unwrap();
4547
4548 let snap = state.metrics.snapshot();
4549 assert_eq!(
4550 snap.http.request_timeouts_total, 1,
4551 "autumn_request_timeouts_total must be incremented on timeout"
4552 );
4553 }
4554
4555 #[tokio::test(start_paused = true)]
4556 async fn request_timeout_response_includes_request_id() {
4557 let mut config = AutumnConfig::default();
4558 config.server.timeouts.request_timeout_ms = Some(100);
4559
4560 let state = crate::state::AppState::for_test();
4561 let router: axum::Router<AppState> = axum::Router::new().route(
4562 "/slow",
4563 axum::routing::get(|| async {
4564 tokio::time::sleep(std::time::Duration::from_secs(60)).await;
4565 "ok"
4566 }),
4567 );
4568
4569 let router = apply_request_timeout_middleware(router, &config, state.metrics.clone())
4570 .layer(RequestIdLayer)
4571 .with_state(state);
4572
4573 let response = router
4574 .oneshot(Request::builder().uri("/slow").body(Body::empty()).unwrap())
4575 .await
4576 .unwrap();
4577
4578 assert_eq!(response.status(), StatusCode::REQUEST_TIMEOUT);
4579 assert!(
4581 response.headers().contains_key("x-request-id"),
4582 "408 response must carry the X-Request-Id header"
4583 );
4584
4585 let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
4587 .await
4588 .unwrap();
4589 let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
4590 assert_eq!(body["status"], 408);
4591 }
4592
4593 #[tokio::test]
4594 async fn request_timeout_disabled_when_none() {
4595 let config = AutumnConfig::default(); let state = crate::state::AppState::for_test();
4598 let router: axum::Router<AppState> =
4599 axum::Router::new().route("/fast", axum::routing::get(|| async { "pong" }));
4600
4601 let router = apply_request_timeout_middleware(router, &config, state.metrics.clone())
4602 .with_state(state);
4603
4604 let response = router
4605 .oneshot(Request::builder().uri("/fast").body(Body::empty()).unwrap())
4606 .await
4607 .unwrap();
4608
4609 assert_eq!(response.status(), StatusCode::OK);
4610 }
4611
4612 #[tokio::test]
4613 async fn request_timeout_zero_treated_as_disabled() {
4614 let mut config = AutumnConfig::default();
4615 config.server.timeouts.request_timeout_ms = Some(0); let state = crate::state::AppState::for_test();
4618 let router: axum::Router<AppState> =
4619 axum::Router::new().route("/fast", axum::routing::get(|| async { "pong" }));
4620
4621 let router = apply_request_timeout_middleware(router, &config, state.metrics.clone())
4622 .with_state(state);
4623
4624 let response = router
4625 .oneshot(Request::builder().uri("/fast").body(Body::empty()).unwrap())
4626 .await
4627 .unwrap();
4628
4629 assert_eq!(response.status(), StatusCode::OK);
4630 }
4631
4632 #[tokio::test(start_paused = true)]
4635 async fn request_timeout_408_without_request_id_layer() {
4636 let mut config = AutumnConfig::default();
4637 config.server.timeouts.request_timeout_ms = Some(100);
4638
4639 let state = crate::state::AppState::for_test();
4640 let router: axum::Router<AppState> = axum::Router::new().route(
4641 "/slow",
4642 axum::routing::get(|| async {
4643 tokio::time::sleep(std::time::Duration::from_secs(60)).await;
4644 "ok"
4645 }),
4646 );
4647
4648 let router = apply_request_timeout_middleware(router, &config, state.metrics.clone())
4650 .with_state(state);
4651
4652 let response = router
4653 .oneshot(Request::builder().uri("/slow").body(Body::empty()).unwrap())
4654 .await
4655 .unwrap();
4656
4657 assert_eq!(response.status(), StatusCode::REQUEST_TIMEOUT);
4658 }
4659}
4660#[derive(Clone, Debug)]
4661pub struct TrustedHostPolicy {
4662 rules: Arc<Vec<String>>,
4663 allow_any: bool,
4664 allow_missing_host: bool,
4665 probe_bypass_paths: Arc<std::collections::HashSet<String>>,
4666}
4667
4668impl TrustedHostPolicy {
4669 pub fn from_config(config: &AutumnConfig) -> Self {
4670 let mut rules: Vec<String> = config
4671 .security
4672 .trusted_hosts
4673 .hosts
4674 .iter()
4675 .map(|h| h.trim().to_ascii_lowercase())
4676 .map(|h| h.trim_end_matches('.').to_owned())
4677 .filter(|h| !h.is_empty())
4678 .collect();
4679 let is_production = matches!(config.profile.as_deref(), Some("prod" | "production"));
4680 if !is_production {
4681 rules.extend(
4682 ["localhost", "127.0.0.1", "::1"]
4683 .into_iter()
4684 .map(std::borrow::ToOwned::to_owned),
4685 );
4686 }
4687 let allow_any = rules.iter().any(|h| h == "*");
4688 let probe_bypass_paths = std::collections::HashSet::from([
4689 config.health.path.clone(),
4690 config.health.live_path.clone(),
4691 config.health.ready_path.clone(),
4692 config.health.startup_path.clone(),
4693 crate::actuator::actuator_route_path(&config.actuator.prefix, "/health"),
4694 ]);
4695 Self {
4696 rules: Arc::new(rules),
4697 allow_any,
4698 allow_missing_host: !is_production,
4699 probe_bypass_paths: Arc::new(probe_bypass_paths),
4700 }
4701 }
4702
4703 #[cfg(feature = "mcp")]
4710 pub const fn allows_missing_host(&self) -> bool {
4711 self.allow_missing_host
4712 }
4713
4714 pub fn allows_host(&self, host: &str) -> bool {
4715 if self.allow_any {
4716 return true;
4717 }
4718 self.rules.iter().any(|rule| {
4719 rule.strip_prefix('.').map_or_else(
4720 || host == rule,
4721 |suffix| {
4722 host == suffix
4723 || host
4724 .strip_suffix(suffix)
4725 .is_some_and(|prefix| prefix.ends_with('.'))
4726 },
4727 )
4728 })
4729 }
4730}
4731
4732#[derive(Clone, Debug)]
4734pub struct RouteVersionMetadata {
4735 pub version: String,
4736 pub sunset_opt_out: bool,
4737 pub secured: bool,
4738 pub required_roles: &'static [&'static str],
4739 pub has_policy: bool,
4740}
4741
4742async fn api_versioning_middleware(
4744 state: axum::extract::State<AppState>,
4745 route_version: Option<axum::extract::Extension<RouteVersionMetadata>>,
4746 request: axum::http::Request<axum::body::Body>,
4747 next: axum::middleware::Next,
4748) -> axum::response::Response {
4749 let Some(axum::extract::Extension(meta)) = route_version else {
4750 return next.run(request).await;
4751 };
4752
4753 let clock = state.clock();
4754 let now = clock.now();
4755
4756 let versions = state.extension::<crate::app::RegisteredApiVersions>();
4757 let matching_version = versions
4758 .as_ref()
4759 .and_then(|v| v.0.iter().find(|av| av.version == meta.version));
4760
4761 let Some(version) = matching_version else {
4762 return next.run(request).await;
4763 };
4764
4765 let is_deprecated = version.deprecated_at.is_some_and(|d| now >= d);
4766 let is_sunset = version.sunset_at.is_some_and(|s| now >= s);
4767
4768 if is_sunset && !meta.sunset_opt_out {
4769 if meta.has_policy {
4770 return next.run(request).await;
4771 }
4772 if meta.secured {
4773 let session = request.extensions().get::<crate::session::Session>();
4774 let mut auth_failed = false;
4775 let mut auth_error = None;
4776 if let Some(session) = session {
4777 if let Err(err) = crate::auth::__check_secured_with_key(
4778 session,
4779 state.auth_session_key(),
4780 meta.required_roles,
4781 )
4782 .await
4783 {
4784 auth_failed = true;
4785 auth_error = Some(err);
4786 }
4787 } else {
4788 auth_failed = true;
4789 auth_error = Some(crate::error::AutumnError::unauthorized_msg(
4790 "authentication required",
4791 ));
4792 }
4793 if auth_failed {
4794 return auth_error.unwrap().into_response();
4795 }
4796 }
4797
4798 let err = crate::error::AutumnError::gone_msg(format!(
4799 "API version '{}' has been sunsetted.",
4800 meta.version
4801 ));
4802 let mut response = err.into_response();
4803 if let Some(sunset) = version.sunset_at {
4804 let http_date = sunset.format("%a, %d %b %Y %H:%M:%S GMT").to_string();
4805 if let Ok(val) = axum::http::HeaderValue::from_str(&http_date) {
4806 response.headers_mut().insert("Sunset", val);
4807 }
4808 }
4809 let deprecation_date = match (version.deprecated_at, version.sunset_at) {
4810 (Some(d), Some(s)) => Some(d.min(s)),
4811 (d, s) => d.or(s),
4812 };
4813 if let Some(date) = deprecation_date {
4814 let timestamp = date.timestamp();
4815 if let Ok(val) = axum::http::HeaderValue::from_str(&format!("@{timestamp}")) {
4816 response.headers_mut().insert("Deprecation", val);
4817 }
4818 }
4819 return response;
4820 }
4821
4822 let mut response = next.run(request).await;
4823
4824 if is_deprecated || is_sunset {
4825 let deprecation_date = match (version.deprecated_at, version.sunset_at) {
4826 (Some(d), Some(s)) => Some(d.min(s)),
4827 (d, s) => d.or(s),
4828 };
4829 if let Some(date) = deprecation_date {
4830 let timestamp = date.timestamp();
4831 if let Ok(val) = axum::http::HeaderValue::from_str(&format!("@{timestamp}")) {
4832 response.headers_mut().insert("Deprecation", val);
4833 }
4834 }
4835 }
4836 if let Some(sunset) = version.sunset_at.filter(|_| is_deprecated || is_sunset) {
4837 let http_date = sunset.format("%a, %d %b %Y %H:%M:%S GMT").to_string();
4838 if let Ok(val) = axum::http::HeaderValue::from_str(&http_date) {
4839 response.headers_mut().insert("Sunset", val);
4840 }
4841 }
4842
4843 response
4844}
4845
4846#[must_use]
4849pub fn check_sunset(
4850 state: &crate::state::AppState,
4851 meta: &RouteVersionMetadata,
4852) -> Option<axum::response::Response> {
4853 let clock = state.clock();
4854 let now = clock.now();
4855
4856 let versions = state.extension::<crate::app::RegisteredApiVersions>();
4857 let matching_version = versions
4858 .as_ref()
4859 .and_then(|v| v.0.iter().find(|av| av.version == meta.version));
4860
4861 let version = matching_version?;
4862 let is_sunset = version.sunset_at.is_some_and(|s| now >= s);
4863
4864 if is_sunset && !meta.sunset_opt_out {
4865 let err = crate::error::AutumnError::gone_msg(format!(
4866 "API version '{}' has been sunsetted.",
4867 meta.version
4868 ));
4869 let mut response = axum::response::IntoResponse::into_response(err);
4870 if let Some(sunset) = version.sunset_at {
4871 let http_date = sunset.format("%a, %d %b %Y %H:%M:%S GMT").to_string();
4872 if let Ok(val) = axum::http::HeaderValue::from_str(&http_date) {
4873 response.headers_mut().insert("Sunset", val);
4874 }
4875 }
4876 let deprecation_date = match (version.deprecated_at, version.sunset_at) {
4877 (Some(d), Some(s)) => Some(d.min(s)),
4878 (d, s) => d.or(s),
4879 };
4880 if let Some(date) = deprecation_date {
4881 let timestamp = date.timestamp();
4882 if let Ok(val) = axum::http::HeaderValue::from_str(&format!("@{timestamp}")) {
4883 response.headers_mut().insert("Deprecation", val);
4884 }
4885 }
4886 return Some(response);
4887 }
4888
4889 None
4890}