use std::time::Duration;
use axum::{Router, routing::MethodRouter};
use crate::{AppContext, Result, config::Config, health, middleware};
#[derive(Clone, Debug)]
pub struct Handler {
pub uri: String,
pub method: MethodRouter<AppContext>,
pub timeout: Option<Duration>,
}
impl Handler {
#[must_use]
pub fn methods(&self) -> Vec<String> {
methods_of(&self.method)
}
}
fn methods_of(router: &MethodRouter<AppContext>) -> Vec<String> {
const FIELDS: [&str; 9] = [
"get", "head", "delete", "options", "patch", "post", "put", "trace", "connect",
];
let text = format!("{router:?}");
let value = |field: &str| text.split(&format!("{field}: ")).nth(1);
let answers = |field: &str| value(field).is_some_and(|rest| !rest.starts_with("None"));
let get = answers("get");
let mut methods: Vec<String> = FIELDS
.iter()
.filter(|field| answers(field) && !(get && **field == "head"))
.map(|field| field.to_ascii_uppercase())
.collect();
if value("fallback").is_some_and(|rest| !rest.starts_with("Default")) {
methods.push("ANY".to_string());
}
methods
}
#[derive(Clone, Default, Debug)]
pub struct Routes {
pub prefix: Option<String>,
pub handlers: Vec<Handler>,
pub timeout: Option<Duration>,
}
impl Routes {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn prefix(mut self, prefix: &str) -> Self {
self.prefix = Some(prefix.to_string());
self
}
#[must_use]
pub fn timeout(mut self, limit: Duration) -> Self {
self.timeout = Some(limit);
self
}
#[must_use]
pub fn add(mut self, path: &str, handler: MethodRouter<AppContext>) -> Self {
self.handlers.push(Handler {
uri: path.to_string(),
method: handler,
timeout: None,
});
self
}
#[must_use]
pub fn add_with_timeout(
mut self,
path: &str,
handler: MethodRouter<AppContext>,
limit: Duration,
) -> Self {
self.handlers.push(Handler {
uri: path.to_string(),
method: handler,
timeout: Some(limit),
});
self
}
#[must_use]
pub fn mounted(&self) -> Vec<(String, String)> {
let prefix = self.prefix.as_deref().unwrap_or_default();
self.handlers
.iter()
.flat_map(|handler| {
let path = join(&[prefix, &handler.uri]);
handler
.methods()
.into_iter()
.map(move |method| (method, path.clone()))
})
.collect()
}
}
fn join(prefixes: &[&str]) -> String {
let joined = prefixes.join("/");
let path = joined
.split('/')
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>()
.join("/");
format!("/{path}")
}
#[derive(Clone, Default, Debug)]
pub struct AppRoutes {
groups: Vec<Routes>,
}
impl AppRoutes {
#[must_use]
pub fn with_default_routes() -> Self {
Self::empty().add_route(health::routes())
}
#[must_use]
pub fn empty() -> Self {
Self::default()
}
#[must_use]
pub fn add_route(mut self, group: Routes) -> Self {
self.groups.push(group);
self
}
#[must_use]
pub fn mounted(&self) -> Vec<(String, String)> {
self.groups.iter().flat_map(Routes::mounted).collect()
}
fn collect(self) -> Vec<(String, MethodRouter<AppContext>)> {
self.groups
.into_iter()
.flat_map(|group| {
let prefix = group.prefix.unwrap_or_default();
let group_limit = group.timeout;
group.handlers.into_iter().map(move |handler| {
let path = join(&[&prefix, &handler.uri]);
match handler.timeout.or(group_limit) {
Some(limit) => (path, middleware::limit_route(handler.method, limit)),
None => (path, handler.method),
}
})
})
.collect()
}
pub fn into_router(self, ctx: AppContext) -> Router {
let mut router = Router::new();
for (path, handler) in self.collect() {
router = router.route(&path, handler);
}
router.with_state(ctx)
}
pub fn into_app(self, ctx: AppContext) -> Result<Router> {
let config: std::sync::Arc<Config> = ctx.config.clone();
middleware::apply(self.into_router(ctx), &config.http)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Environment, config::Defaults};
use axum::{
body::Body,
http::{Request, StatusCode},
routing::{any, connect, delete, get, head, options, patch, post, put, trace},
};
use std::time::Duration;
use tower::ServiceExt;
fn context() -> AppContext {
context_with(0)
}
fn context_with(seconds: u64) -> AppContext {
let defaults = Defaults {
database: "",
..Defaults::default()
};
let mut config = Config::from_lookup(&Environment::Test, &defaults, |_| None).unwrap();
config.http.timeout_seconds = seconds;
#[cfg(not(feature = "sea-orm"))]
return AppContext::for_test(Environment::Test, config);
#[cfg(feature = "sea-orm")]
return AppContext::new(
Environment::Test,
sea_orm::DatabaseConnection::default(),
config,
);
}
async fn status(router: Router, method: &str, path: &str) -> StatusCode {
let request = Request::builder()
.method(method)
.uri(path)
.body(Body::empty())
.unwrap();
router.oneshot(request).await.unwrap().status()
}
fn table() -> AppRoutes {
AppRoutes::empty().add_route(
Routes::new()
.prefix("/api/things")
.add("/", get(|| async { "list" }))
.add("/", post(|| async { "create" }))
.add("/{id}", get(|| async { "show" })),
)
}
#[test]
fn groups_expose_what_they_mount() {
let group = Routes::new()
.prefix("/fhir")
.add("/metadata", get(|| async { "" }));
assert_eq!(group.prefix.as_deref(), Some("/fhir"));
assert!(group.handlers.iter().any(|h| h.uri == "/metadata"));
assert_eq!(Routes::new().prefix, None);
}
#[tokio::test]
async fn a_trailing_slash_route_is_served_at_the_prefix() {
let router = table().into_router(context());
assert_eq!(status(router, "GET", "/api/things").await, StatusCode::OK);
}
#[tokio::test]
async fn two_methods_on_one_path_merge() {
let router = table().into_router(context());
assert_eq!(
status(router.clone(), "GET", "/api/things").await,
StatusCode::OK
);
assert_eq!(status(router, "POST", "/api/things").await, StatusCode::OK);
}
#[tokio::test]
async fn path_parameters_route() {
let router = table().into_router(context());
assert_eq!(status(router, "GET", "/api/things/7").await, StatusCode::OK);
}
#[tokio::test]
async fn an_unknown_path_is_404() {
let router = table().into_router(context());
assert_eq!(status(router, "GET", "/nope").await, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn into_app_applies_the_standard_middleware() {
let router = table().into_app(context()).unwrap();
let request = Request::builder()
.uri("/api/things")
.body(Body::empty())
.unwrap();
let response = router.oneshot(request).await.unwrap();
assert!(response.headers().contains_key(middleware::X_REQUEST_ID));
}
fn method_names(router: &MethodRouter<AppContext>) -> Vec<String> {
methods_of(router)
}
#[test]
fn the_debug_text_still_says_what_is_mounted() {
async fn answer() {}
let cases: [(MethodRouter<AppContext>, &str); 8] = [
(get(answer), "GET"),
(delete(answer), "DELETE"),
(options(answer), "OPTIONS"),
(patch(answer), "PATCH"),
(post(answer), "POST"),
(put(answer), "PUT"),
(trace(answer), "TRACE"),
(connect(answer), "CONNECT"),
];
for (router, method) in cases {
assert_eq!(method_names(&router), [method]);
}
assert_eq!(method_names(&head(answer)), ["HEAD"]);
assert_eq!(method_names(&any(answer)), ["ANY"]);
let text = format!("{:?}", get::<_, _, AppContext>(answer));
for field in [
"get: BoxedHandler",
"head: None",
"delete: None",
"options: None",
"patch: None",
"post: None",
"put: None",
"trace: None",
"connect: None",
"fallback: Default(",
] {
assert!(text.contains(field), "{field} is not in {text}");
}
}
#[test]
fn methods_survive_a_layer() {
async fn answer() {}
let router: MethodRouter<AppContext> = get(answer).post(answer);
let layered = middleware::limit_route(router, Duration::from_secs(1));
assert_eq!(method_names(&layered), ["GET", "POST"]);
}
#[test]
fn head_is_left_out_beside_get() {
async fn answer() {}
let router: MethodRouter<AppContext> = get(answer).head(answer).post(answer);
assert_eq!(method_names(&router), ["GET", "POST"]);
}
#[test]
fn mounted_lists_every_method_and_full_path() {
let group = Routes::new()
.prefix("/fhir")
.add("/metadata", get(|| async { "" }).delete(|| async { "" }));
let routes = table().add_route(group);
let want = [
("GET", "/api/things"),
("POST", "/api/things"),
("GET", "/api/things/{id}"),
("GET", "/fhir/metadata"),
("DELETE", "/fhir/metadata"),
]
.map(|(method, path)| (method.to_string(), path.to_string()));
assert_eq!(routes.mounted(), want);
}
#[test]
fn mounted_includes_the_health_routes_and_a_handler_for_any_method() {
let routes = AppRoutes::with_default_routes()
.add_route(Routes::new().add("/hook", any(|| async { "" })));
let all = routes.mounted();
assert!(all.contains(&("GET".to_string(), "/_readiness".to_string())));
assert!(all.contains(&("ANY".to_string(), "/hook".to_string())));
}
fn slow_table(limit: Duration) -> AppRoutes {
async fn slow() -> &'static str {
tokio::time::sleep(Duration::from_secs(90)).await;
"done"
}
AppRoutes::empty()
.add_route(Routes::new().add("/plain", get(slow)).add_with_timeout(
"/export",
get(slow),
limit,
))
.add_route(
Routes::new()
.prefix("/reports")
.timeout(limit)
.add("/one", get(slow))
.add_with_timeout("/two", get(slow), Duration::from_secs(1)),
)
}
#[tokio::test(start_paused = true)]
async fn a_route_with_a_longer_limit_outlives_the_stack_and_others_do_not() {
let app = slow_table(Duration::from_mins(2))
.into_app(context_with(60))
.unwrap();
assert_eq!(status(app.clone(), "GET", "/export").await, StatusCode::OK);
assert_eq!(
status(app, "GET", "/plain").await,
StatusCode::REQUEST_TIMEOUT
);
}
#[tokio::test(start_paused = true)]
async fn a_route_is_still_cut_off_at_its_own_limit() {
let app = slow_table(Duration::from_secs(75))
.into_app(context_with(60))
.unwrap();
assert_eq!(
status(app, "GET", "/export").await,
StatusCode::REQUEST_TIMEOUT
);
}
#[tokio::test(start_paused = true)]
async fn a_route_limit_answers_as_a_problem() {
let app = slow_table(Duration::from_secs(75))
.into_app(context_with(60))
.unwrap();
let request = Request::builder()
.uri("/export")
.body(Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(
response.headers()[axum::http::header::CONTENT_TYPE],
axa_problem::PROBLEM_JSON
);
}
#[tokio::test(start_paused = true)]
async fn a_group_limit_applies_and_a_routes_own_limit_beats_it() {
let app = slow_table(Duration::from_mins(2))
.into_app(context_with(60))
.unwrap();
assert_eq!(
status(app.clone(), "GET", "/reports/one").await,
StatusCode::OK
);
assert_eq!(
status(app, "GET", "/reports/two").await,
StatusCode::REQUEST_TIMEOUT
);
}
#[tokio::test(start_paused = true)]
async fn a_shorter_route_limit_applies_with_the_stack_timeout_off() {
let app = slow_table(Duration::from_secs(1))
.into_app(context_with(0))
.unwrap();
assert_eq!(
status(app, "GET", "/export").await,
StatusCode::REQUEST_TIMEOUT
);
}
#[tokio::test(start_paused = true)]
async fn a_zero_route_limit_means_no_limit() {
let app = slow_table(Duration::ZERO)
.into_app(context_with(60))
.unwrap();
assert_eq!(status(app, "GET", "/export").await, StatusCode::OK);
}
#[tokio::test(start_paused = true)]
async fn a_routes_own_limit_beats_a_timeout_override() {
let mut ctx = context_with(60);
let mut config = (*ctx.config).clone();
config.http.timeout_overrides = vec![crate::config::TimeoutOverride {
path_suffix: "/export".to_string(),
seconds: 1,
}];
ctx.config = std::sync::Arc::new(config);
let app = slow_table(Duration::from_mins(2)).into_app(ctx).unwrap();
assert_eq!(status(app, "GET", "/export").await, StatusCode::OK);
}
#[tokio::test(start_paused = true)]
async fn a_route_limit_covers_one_method_on_a_shared_path() {
async fn slow() -> &'static str {
tokio::time::sleep(Duration::from_secs(90)).await;
"done"
}
let group = Routes::new()
.add_with_timeout("/thing", get(slow), Duration::from_mins(2))
.add("/thing", post(slow));
let app = AppRoutes::empty()
.add_route(group)
.into_app(context_with(60))
.unwrap();
assert_eq!(status(app.clone(), "GET", "/thing").await, StatusCode::OK);
assert_eq!(
status(app, "POST", "/thing").await,
StatusCode::REQUEST_TIMEOUT
);
}
#[test]
fn join_normalises_slashes() {
assert_eq!(join(&["/api/folders", "/"]), "/api/folders");
assert_eq!(join(&["/api/folders", "/{id}"]), "/api/folders/{id}");
assert_eq!(join(&["", "/healthz"]), "/healthz");
assert_eq!(join(&["/", "/"]), "/");
}
}