use axum::http::Method;
use axum::response::Response;
use std::future::Future;
use std::pin::Pin;
pub type CustomViewFuture = Pin<Box<dyn Future<Output = Response> + Send + 'static>>;
pub type CustomViewHandler =
fn(crate::sql::Pool, axum::http::Request<axum::body::Body>) -> CustomViewFuture;
pub struct AdminCustomView {
pub table: &'static str,
pub suffix: &'static str,
pub method: Method,
pub label: &'static str,
pub handler: CustomViewHandler,
pub perm: Option<&'static str>,
}
impl AdminCustomView {
#[must_use]
pub fn mount_method(&self) -> Method {
match self.method.as_str() {
"POST" | "PUT" | "PATCH" | "DELETE" | "QUERY" => self.method.clone(),
_ => Method::GET,
}
}
#[must_use]
pub fn required_perm(&self) -> &'static str {
self.perm.unwrap_or(match self.mount_method().as_str() {
"GET" | "QUERY" => "view",
_ => "change",
})
}
}
inventory::collect!(AdminCustomView);
#[must_use]
pub fn for_table(table: &str) -> Vec<&'static AdminCustomView> {
inventory::iter::<AdminCustomView>
.into_iter()
.filter(|v| v.table == table)
.collect()
}
const BUILT_IN_SUFFIXES: &[&str] = &[
"",
"new",
"__action",
"__autocomplete",
"{pk}",
"{pk}/edit",
"{pk}/delete",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Clash {
Reserved,
Shadows(&'static str),
}
#[must_use]
pub(crate) fn clash(suffix: &str) -> Option<Clash> {
let trimmed = suffix.trim_matches('/');
if BUILT_IN_SUFFIXES.contains(&trimmed) {
return Some(Clash::Reserved);
}
BUILT_IN_SUFFIXES
.iter()
.find(|b| !b.is_empty() && covers(trimmed, b))
.map(|b| Clash::Shadows(b))
}
fn covers(custom: &str, built_in: &str) -> bool {
let is_param = |s: &str| s.starts_with('{') && s.ends_with('}');
let (mut c, mut b) = (custom.split('/'), built_in.split('/'));
loop {
match (c.next(), b.next()) {
(None, None) => return true,
(Some(cs), Some(_)) if cs.starts_with("{*") && cs.ends_with('}') => return true,
(Some(cs), Some(bs)) if is_param(cs) || (!is_param(bs) && cs == bs) => {}
_ => return false,
}
}
}
#[macro_export]
macro_rules! register_admin_view {
($table:expr, $suffix:expr, $method:expr, $label:expr, $handler:expr $(,)?) => {
$crate::register_admin_view!(@entry $table, $suffix, $method, $label, $handler, ::core::option::Option::None);
};
($table:expr, $suffix:expr, $method:expr, $label:expr, $handler:expr, perm = $perm:expr $(,)?) => {
$crate::register_admin_view!(@entry $table, $suffix, $method, $label, $handler, ::core::option::Option::Some($perm));
};
(@entry $table:expr, $suffix:expr, $method:expr, $label:expr, $handler:expr, $perm:expr) => {
$crate::inventory::submit! {
$crate::admin::custom_views::AdminCustomView {
table: $table,
suffix: $suffix,
method: $method,
label: $label,
handler: {
fn __rustango_admin_view_handler(
pool: $crate::sql::Pool,
req: ::axum::http::Request<::axum::body::Body>,
) -> $crate::admin::custom_views::CustomViewFuture {
::std::boxed::Box::pin(($handler)(pool, req))
}
__rustango_admin_view_handler
},
perm: $perm,
}
}
};
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reserved_suffixes_includes_built_in_routes() {
for s in [
"",
"new",
"__action",
"__autocomplete",
"{pk}",
"{pk}/edit",
"{pk}/delete",
] {
assert_eq!(clash(s), Some(Clash::Reserved), "{s}");
}
}
#[test]
fn reserved_suffixes_strips_surrounding_slashes() {
for s in ["/new", "/new/", "new/"] {
assert_eq!(clash(s), Some(Clash::Reserved), "{s}");
}
}
#[test]
fn user_suffixes_are_not_reserved() {
for s in ["duplicate", "copy/{id}", "export.csv", "preview"] {
assert_eq!(clash(s), None, "{s}");
}
}
#[test]
fn a_renamed_param_shadows_the_built_in_it_matches() {
assert_eq!(clash("{id}"), Some(Clash::Shadows("new")));
assert_eq!(clash("{id}/edit"), Some(Clash::Shadows("{pk}/edit")));
assert_eq!(
clash("/{slug}/delete/"),
Some(Clash::Shadows("{pk}/delete"))
);
assert_eq!(clash("{a}/{b}"), Some(Clash::Shadows("{pk}/edit")));
assert_eq!(clash("{*rest}"), Some(Clash::Shadows("new")));
}
#[test]
fn suffixes_that_match_no_built_in_do_not_clash() {
for s in [
"duplicate",
"copy/{id}",
"{id}/duplicate",
"export.csv",
"{id}/edit/x",
] {
assert_eq!(clash(s), None, "{s}");
}
}
#[tokio::test]
async fn axum_routes_a_shadowing_view_over_the_built_in() {
use axum::body::Body;
use axum::http::{Request, StatusCode};
use axum::routing::{get, post};
use tower::ServiceExt as _;
let app = axum::Router::new()
.route("/{table}/new", get(|| async { "create" }))
.route("/{table}/{pk}", get(|| async { "detail" }))
.route("/post/{id}", post(|| async { "custom" }));
for uri in ["/post/7", "/post/new"] {
let req = Request::get(uri).body(Body::empty()).unwrap();
let res = app.clone().oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::METHOD_NOT_ALLOWED, "{uri}");
}
}
#[test]
fn for_table_returns_empty_when_no_registrations() {
let v = for_table("nonexistent_table");
assert!(v.is_empty());
}
}