use axum::routing::{delete as route_delete, get, post, put};
use axum::Router;
use std::collections::HashSet;
use std::sync::LazyLock;
pub static APP_MAP: LazyLock<HashSet<&'static str>> =
LazyLock::new(|| HashSet::from(["oapc", "admin", "api", "farm", "oapi", "cashier", "scene"]));
pub static DENY_APP_LIST: LazyLock<HashSet<&'static str>> =
LazyLock::new(|| HashSet::from(["common"]));
pub const DEFAULT_APP: &str = "index";
pub const DEFAULT_CONTROLLER: &str = "Index";
pub const DEFAULT_ACTION: &str = "index";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedPath {
pub app: String,
pub controller: String,
pub action: String,
}
impl ParsedPath {
pub fn new(
app: impl Into<String>,
controller: impl Into<String>,
action: impl Into<String>,
) -> Self {
Self {
app: app.into(),
controller: controller.into(),
action: action.into(),
}
}
}
pub fn parse_path(uri: &str) -> ParsedPath {
let path = uri.split('?').next().unwrap_or(uri);
let path = path.trim_start_matches('/');
if path.is_empty() {
return ParsedPath::new(DEFAULT_APP, DEFAULT_CONTROLLER, DEFAULT_ACTION);
}
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
match segments.len() {
1 => ParsedPath::new(DEFAULT_APP, capitalize_first(segments[0]), DEFAULT_ACTION),
2 => {
if is_app_in_map(segments[0]) {
ParsedPath::new(segments[0], capitalize_first(segments[1]), DEFAULT_ACTION)
} else {
ParsedPath::new(
DEFAULT_APP,
capitalize_first(segments[0]),
segments[1].to_string(),
)
}
}
_ => {
if is_app_in_map(segments[0]) {
ParsedPath::new(
segments[0],
capitalize_first(segments[1]),
segments[2].to_string(),
)
} else {
ParsedPath::new(
DEFAULT_APP,
capitalize_first(segments[0]),
segments[1].to_string(),
)
}
}
}
}
pub fn is_app_in_map(name: &str) -> bool {
APP_MAP.contains(name) && !DENY_APP_LIST.contains(name)
}
fn capitalize_first(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
pub struct RouterBuilder<S = ()> {
inner: Router<S>,
}
impl<S> RouterBuilder<S>
where
S: Clone + Send + Sync + 'static,
{
pub fn new() -> Self {
Self {
inner: Router::new(),
}
}
pub fn with_router(inner: Router<S>) -> Self {
Self { inner }
}
pub fn with_state<S2>(self, state: S) -> RouterBuilder<S2> {
RouterBuilder {
inner: self.inner.with_state(state),
}
}
pub fn get<H, T>(self, path: &str, handler: H) -> Self
where
H: axum::handler::Handler<T, S>,
T: 'static,
{
Self {
inner: self.inner.route(path, get(handler)),
}
}
pub fn post<H, T>(self, path: &str, handler: H) -> Self
where
H: axum::handler::Handler<T, S>,
T: 'static,
{
Self {
inner: self.inner.route(path, post(handler)),
}
}
pub fn put<H, T>(self, path: &str, handler: H) -> Self
where
H: axum::handler::Handler<T, S>,
T: 'static,
{
Self {
inner: self.inner.route(path, put(handler)),
}
}
pub fn delete<H, T>(self, path: &str, handler: H) -> Self
where
H: axum::handler::Handler<T, S>,
T: 'static,
{
Self {
inner: self.inner.route(path, route_delete(handler)),
}
}
pub fn ws<H: crate::websocket_route::WsHandler>(self, path: &str, handler: H) -> Self {
let mr: axum::routing::MethodRouter<()> =
crate::websocket_route::ws_handler(handler);
let mr_s: axum::routing::MethodRouter<S> = mr.with_state(());
Self {
inner: self.inner.route(path, mr_s),
}
}
pub fn layer<L>(self, layer: L) -> Self
where
L: tower::Layer<axum::routing::Route> + Clone + Send + Sync + 'static,
L::Service: tower::Service<axum::extract::Request> + Clone + Send + Sync + 'static,
<L::Service as tower::Service<axum::extract::Request>>::Response:
axum::response::IntoResponse + 'static,
<L::Service as tower::Service<axum::extract::Request>>::Error: Into<Infallible> + 'static,
<L::Service as tower::Service<axum::extract::Request>>::Future: Send + 'static,
{
Self {
inner: self.inner.layer(layer),
}
}
pub fn merge(self, other: Router<S>) -> Self {
Self {
inner: self.inner.merge(other),
}
}
pub fn build(self) -> Router<S> {
self.inner
}
}
impl Default for RouterBuilder {
fn default() -> Self {
Self::new()
}
}
use std::convert::Infallible;
#[derive(Default)]
pub struct ResourceRoutes {
pub index: Option<axum::routing::MethodRouter>,
pub create: Option<axum::routing::MethodRouter>,
pub store: Option<axum::routing::MethodRouter>,
pub show: Option<axum::routing::MethodRouter>,
pub edit: Option<axum::routing::MethodRouter>,
pub update: Option<axum::routing::MethodRouter>,
pub destroy: Option<axum::routing::MethodRouter>,
}
impl ResourceRoutes {
pub fn new() -> Self {
Self::default()
}
}
pub fn resource(name: &str, routes: ResourceRoutes) -> axum::Router {
let base = format!("/{name}");
let with_id = format!("/{name}/{{id}}");
let create_path = format!("/{name}/create");
let edit_path = format!("/{name}/{{id}}/edit");
let mut router = axum::Router::new();
let mut base_methods = axum::routing::MethodRouter::new();
let mut has_base = false;
if let Some(h) = routes.index {
base_methods = base_methods.merge(h);
has_base = true;
}
if let Some(h) = routes.store {
base_methods = base_methods.merge(h);
has_base = true;
}
if has_base {
router = router.route(&base, base_methods);
}
if let Some(h) = routes.create {
router = router.route(&create_path, h);
}
let mut id_methods = axum::routing::MethodRouter::new();
let mut has_id = false;
if let Some(h) = routes.show {
id_methods = id_methods.merge(h);
has_id = true;
}
if let Some(h) = routes.update {
id_methods = id_methods.merge(h);
has_id = true;
}
if let Some(h) = routes.destroy {
id_methods = id_methods.merge(h);
has_id = true;
}
if has_id {
router = router.route(&with_id, id_methods);
}
if let Some(h) = routes.edit {
router = router.route(&edit_path, h);
}
router
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::{Method, Request, StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
#[test]
fn test_parse_path_root() {
let p = parse_path("/");
assert_eq!(p, ParsedPath::new("index", "Index", "index"));
}
#[test]
fn test_parse_path_empty() {
let p = parse_path("");
assert_eq!(p, ParsedPath::new("index", "Index", "index"));
}
#[test]
fn test_parse_path_single_segment() {
let p = parse_path("/customer");
assert_eq!(p, ParsedPath::new("index", "Customer", "index"));
}
#[test]
fn test_parse_path_two_segments_no_app() {
let p = parse_path("/customer/list");
assert_eq!(p, ParsedPath::new("index", "Customer", "list"));
}
#[test]
fn test_parse_path_three_segments_with_app() {
let p = parse_path("/oapc/customer/index");
assert_eq!(p, ParsedPath::new("oapc", "Customer", "index"));
}
#[test]
fn test_parse_path_app_in_map_two_segments() {
let p = parse_path("/admin/login");
assert_eq!(p, ParsedPath::new("admin", "Login", "index"));
}
#[test]
fn test_parse_path_all_seven_apps() {
for app in ["oapc", "admin", "api", "farm", "oapi", "cashier", "scene"] {
let p = parse_path(&format!("/{app}/customer/index"));
assert_eq!(p, ParsedPath::new(app, "Customer", "index"));
}
}
#[test]
fn test_parse_path_deny_common_app() {
let p = parse_path("/common/customer/index");
assert_eq!(p, ParsedPath::new("index", "Common", "customer"));
}
#[test]
fn test_parse_path_with_query_string() {
let p = parse_path("/oapc/customer/index?id=1&page=2");
assert_eq!(p, ParsedPath::new("oapc", "Customer", "index"));
}
#[test]
fn test_parse_path_with_trailing_slash() {
let p = parse_path("/oapc/customer/index/");
assert_eq!(p, ParsedPath::new("oapc", "Customer", "index"));
}
#[test]
fn test_parse_path_double_slash() {
let p = parse_path("//oapc//customer//index");
assert_eq!(p, ParsedPath::new("oapc", "Customer", "index"));
}
#[test]
fn test_parse_path_capitalize_first_only() {
let p = parse_path("/customerList");
assert_eq!(p, ParsedPath::new("index", "CustomerList", "index"));
}
#[test]
fn test_is_app_in_map_all_seven() {
for app in ["oapc", "admin", "api", "farm", "oapi", "cashier", "scene"] {
assert!(is_app_in_map(app), "{app} should be in app_map");
}
}
#[test]
fn test_is_app_in_map_deny_common() {
assert!(!is_app_in_map("common"));
}
#[test]
fn test_is_app_in_map_unknown_app() {
assert!(!is_app_in_map("unknown"));
assert!(!is_app_in_map(""));
}
#[tokio::test]
async fn test_router_builder_get() {
let router = RouterBuilder::new()
.get("/ping", || async { "pong" })
.build();
let request = Request::builder()
.method(Method::GET)
.uri("/ping")
.body(Body::empty())
.unwrap();
let response = router.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let bytes = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&bytes[..], b"pong");
}
#[tokio::test]
async fn test_router_builder_post() {
let router = RouterBuilder::new()
.post("/echo", |body: Body| async move {
let bytes = body
.collect()
.await
.map_err(|_| ())
.map(|b| b.to_bytes())
.unwrap_or_default();
String::from_utf8_lossy(&bytes).to_string()
})
.build();
let request = Request::builder()
.method(Method::POST)
.uri("/echo")
.body(Body::from("hello"))
.unwrap();
let response = router.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let bytes = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&bytes[..], b"hello");
}
#[tokio::test]
async fn test_router_builder_multiple_methods() {
let router = RouterBuilder::new()
.get("/items", || async { "list" })
.post("/items", || async { "create" })
.put("/items/1", || async { "update" })
.delete("/items/1", || async { "delete" })
.build();
let req = Request::builder()
.method(Method::GET)
.uri("/items")
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let req = Request::builder()
.method(Method::POST)
.uri("/items")
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let req = Request::builder()
.method(Method::PUT)
.uri("/items/1")
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let req = Request::builder()
.method(Method::DELETE)
.uri("/items/1")
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_router_builder_not_found() {
let router = RouterBuilder::new()
.get("/ping", || async { "pong" })
.build();
let request = Request::builder()
.method(Method::GET)
.uri("/unknown")
.body(Body::empty())
.unwrap();
let response = router.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_router_builder_merge() {
let r1 = RouterBuilder::new().get("/a", || async { "A" }).build();
let r2 = RouterBuilder::new().get("/b", || async { "B" }).build();
let router = RouterBuilder::new().merge(r1).merge(r2).build();
for path in ["/a", "/b"] {
let request = Request::builder()
.method(Method::GET)
.uri(path)
.body(Body::empty())
.unwrap();
let response = router.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
}
#[tokio::test]
async fn test_router_builder_default() {
let router = RouterBuilder::default().build();
let request = Request::builder()
.method(Method::GET)
.uri("/")
.body(Body::empty())
.unwrap();
let response = router.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_resource_all_seven_handlers() {
let routes = ResourceRoutes {
index: Some(axum::routing::get(|| async { "index" })),
create: Some(axum::routing::get(|| async { "create" })),
store: Some(axum::routing::post(|| async { "store" })),
show: Some(axum::routing::get(|| async { "show" })),
edit: Some(axum::routing::get(|| async { "edit" })),
update: Some(axum::routing::put(|| async { "update" })),
destroy: Some(axum::routing::delete(|| async { "destroy" })),
};
let router = resource("users", routes);
let req = Request::builder()
.method(Method::GET)
.uri("/users")
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let req = Request::builder()
.method(Method::POST)
.uri("/users")
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let req = Request::builder()
.method(Method::GET)
.uri("/users/create")
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let req = Request::builder()
.method(Method::GET)
.uri("/users/1")
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let req = Request::builder()
.method(Method::GET)
.uri("/users/1/edit")
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let req = Request::builder()
.method(Method::PUT)
.uri("/users/1")
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let req = Request::builder()
.method(Method::DELETE)
.uri("/users/1")
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_resource_partial_handlers_only_index_and_store() {
let routes = ResourceRoutes {
index: Some(axum::routing::get(|| async { "list" })),
store: Some(axum::routing::post(|| async { "create" })),
..Default::default()
};
let router = resource("articles", routes);
let req = Request::builder()
.method(Method::GET)
.uri("/articles")
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let req = Request::builder()
.method(Method::POST)
.uri("/articles")
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let req = Request::builder()
.method(Method::GET)
.uri("/articles/1")
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let req = Request::builder()
.method(Method::GET)
.uri("/articles/create")
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_resource_only_id_routes() {
let routes = ResourceRoutes {
show: Some(axum::routing::get(|| async { "show" })),
update: Some(axum::routing::put(|| async { "update" })),
destroy: Some(axum::routing::delete(|| async { "destroy" })),
..Default::default()
};
let router = resource("orders", routes);
let req = Request::builder()
.method(Method::GET)
.uri("/orders")
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let req = Request::builder()
.method(Method::GET)
.uri("/orders/1")
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let req = Request::builder()
.method(Method::PUT)
.uri("/orders/1")
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let req = Request::builder()
.method(Method::DELETE)
.uri("/orders/1")
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_resource_empty_routes() {
let routes = ResourceRoutes::new();
let router = resource("widgets", routes);
let req = Request::builder()
.method(Method::GET)
.uri("/widgets")
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_resource_merged_into_router_builder() {
let routes = ResourceRoutes {
index: Some(axum::routing::get(|| async { "list" })),
store: Some(axum::routing::post(|| async { "create" })),
show: Some(axum::routing::get(|| async { "show" })),
..Default::default()
};
let resource_router = resource("users", routes);
let router = RouterBuilder::new()
.merge(resource_router)
.get("/health", || async { "ok" })
.build();
let req = Request::builder()
.method(Method::GET)
.uri("/health")
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let req = Request::builder()
.method(Method::GET)
.uri("/users")
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let req = Request::builder()
.method(Method::POST)
.uri("/users")
.body(Body::empty())
.unwrap();
let resp = router.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let req = Request::builder()
.method(Method::GET)
.uri("/users/42")
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_resource_body_content() {
let routes = ResourceRoutes {
index: Some(axum::routing::get(|| async { "user list" })),
..Default::default()
};
let router = resource("users", routes);
let req = Request::builder()
.method(Method::GET)
.uri("/users")
.body(Body::empty())
.unwrap();
let resp = router.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&bytes[..], b"user list");
}
}