use std::sync::Arc;
use rainier_http::{IntoResponse, Method, Request, Response};
use rainier_middleware::{IntoMiddlewareStack, MiddlewareStack};
use rainier_support::{str, BoxedFuture, Error, ErrorKind};
use crate::handler::RouteHandler;
use crate::router::Router;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ResourceAction {
Index,
Create,
Store,
Show,
Edit,
Update,
Destroy,
}
impl ResourceAction {
pub const ALL: [ResourceAction; 7] = [
ResourceAction::Index,
ResourceAction::Create,
ResourceAction::Store,
ResourceAction::Show,
ResourceAction::Edit,
ResourceAction::Update,
ResourceAction::Destroy,
];
pub const API: [ResourceAction; 5] = [
ResourceAction::Index,
ResourceAction::Store,
ResourceAction::Show,
ResourceAction::Update,
ResourceAction::Destroy,
];
pub fn as_str(self) -> &'static str {
match self {
ResourceAction::Index => "index",
ResourceAction::Create => "create",
ResourceAction::Store => "store",
ResourceAction::Show => "show",
ResourceAction::Edit => "edit",
ResourceAction::Update => "update",
ResourceAction::Destroy => "destroy",
}
}
fn methods(self) -> Vec<Method> {
match self {
ResourceAction::Index
| ResourceAction::Create
| ResourceAction::Show
| ResourceAction::Edit => vec![Method::GET],
ResourceAction::Store => vec![Method::POST],
ResourceAction::Update => vec![Method::PUT, Method::PATCH],
ResourceAction::Destroy => vec![Method::DELETE],
}
}
fn uri_suffix(self, param: &str) -> String {
match self {
ResourceAction::Index | ResourceAction::Store => String::new(),
ResourceAction::Create => "/create".to_string(),
ResourceAction::Show | ResourceAction::Update | ResourceAction::Destroy => {
format!("/{{{param}}}")
}
ResourceAction::Edit => format!("/{{{param}}}/edit"),
}
}
}
#[derive(Default, Clone, Debug)]
pub struct ControllerMiddleware {
entries: Vec<(Vec<ResourceAction>, MiddlewareStack)>,
}
impl ControllerMiddleware {
pub fn new() -> Self {
Self::default()
}
pub fn always(mut self, middleware: impl IntoMiddlewareStack) -> Self {
self.entries.push((ResourceAction::ALL.to_vec(), middleware.into_middleware_stack()));
self
}
pub fn only(
mut self,
actions: impl IntoIterator<Item = ResourceAction>,
middleware: impl IntoMiddlewareStack,
) -> Self {
self.entries.push((actions.into_iter().collect(), middleware.into_middleware_stack()));
self
}
pub fn except(
mut self,
actions: impl IntoIterator<Item = ResourceAction>,
middleware: impl IntoMiddlewareStack,
) -> Self {
let excluded: Vec<ResourceAction> = actions.into_iter().collect();
let included = ResourceAction::ALL.into_iter().filter(|a| !excluded.contains(a)).collect();
self.entries.push((included, middleware.into_middleware_stack()));
self
}
pub fn for_action(&self, action: ResourceAction) -> MiddlewareStack {
self.entries
.iter()
.filter(|(actions, _)| actions.contains(&action))
.fold(MiddlewareStack::new(), |stack, (_, middleware)| {
stack.with_stack(middleware.clone())
})
}
pub fn is_empty(&self) -> bool {
self.entries.iter().all(|(_, stack)| stack.is_empty())
}
}
#[async_trait::async_trait]
pub trait ResourceController: Send + Sync + 'static {
async fn index(&self, request: Request) -> Response {
unsupported(request, "index")
}
async fn create(&self, request: Request) -> Response {
unsupported(request, "create")
}
async fn store(&self, request: Request) -> Response {
unsupported(request, "store")
}
async fn show(&self, request: Request) -> Response {
unsupported(request, "show")
}
async fn edit(&self, request: Request) -> Response {
unsupported(request, "edit")
}
async fn update(&self, request: Request) -> Response {
unsupported(request, "update")
}
async fn destroy(&self, request: Request) -> Response {
unsupported(request, "destroy")
}
fn middleware(&self) -> ControllerMiddleware {
ControllerMiddleware::new()
}
}
fn unsupported(request: Request, action: &str) -> Response {
Error::new(
ErrorKind::MethodNotAllowed,
format!("This resource does not support the `{action}` action."),
)
.with_details(serde_json_path(request.path()))
.into_response()
}
fn serde_json_path(path: &str) -> serde_json::Value {
serde_json::json!({ "path": path })
}
struct ResourceHandler {
controller: Arc<dyn ResourceController>,
action: ResourceAction,
}
impl RouteHandler for ResourceHandler {
fn call(&self, request: Request) -> BoxedFuture<Response> {
let controller = Arc::clone(&self.controller);
let action = self.action;
Box::pin(async move {
match action {
ResourceAction::Index => controller.index(request).await,
ResourceAction::Create => controller.create(request).await,
ResourceAction::Store => controller.store(request).await,
ResourceAction::Show => controller.show(request).await,
ResourceAction::Edit => controller.edit(request).await,
ResourceAction::Update => controller.update(request).await,
ResourceAction::Destroy => controller.destroy(request).await,
}
})
}
}
impl Router {
pub fn resource(&mut self, name: &str, controller: Arc<dyn ResourceController>) {
self.resource_actions(name, controller, &ResourceAction::ALL);
}
pub fn api_resource(&mut self, name: &str, controller: Arc<dyn ResourceController>) {
self.resource_actions(name, controller, &ResourceAction::API);
}
pub fn resource_actions(
&mut self,
name: &str,
controller: Arc<dyn ResourceController>,
actions: &[ResourceAction],
) {
let base = format!("/{}", name.trim_matches('/'));
let param = str::singular(name.rsplit('/').next().unwrap_or(name));
let middleware = controller.middleware();
for action in ResourceAction::ALL {
if !actions.contains(&action) {
continue;
}
let handler: Arc<dyn RouteHandler> =
Arc::new(ResourceHandler { controller: Arc::clone(&controller), action });
let uri = format!("{base}{}", action.uri_suffix(¶m));
let route_name = format!("{}.{}", name.replace('/', "."), action.as_str());
let route = self.add_erased(action.methods(), uri, handler);
route.name(route_name);
let for_action = middleware.for_action(action);
if !for_action.is_empty() {
route.middleware(for_action);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::router::CompiledRouter;
use rainier_http::StatusCode;
struct Posts;
#[async_trait::async_trait]
impl ResourceController for Posts {
async fn index(&self, _: Request) -> Response {
Response::text("index")
}
async fn show(&self, request: Request) -> Response {
Response::text(format!("show:{}", request.route_param("post").unwrap_or("?")))
}
async fn store(&self, _: Request) -> Response {
Response::text("store")
}
async fn destroy(&self, _: Request) -> Response {
Response::text("destroy")
}
}
fn compiled(router: Router) -> CompiledRouter {
router.compile(&rainier_container::Container::new()).expect("compiles")
}
async fn body_of(response: Response) -> String {
String::from_utf8(response.into_http().into_body().collect().await.unwrap().to_vec())
.unwrap()
}
fn request(method: Method, uri: &str) -> Request {
Request::builder().method(method).uri(uri).build()
}
fn posts_router() -> Router {
let mut router = Router::new();
router.resource("posts", Arc::new(Posts));
router
}
struct Tag(&'static str);
#[async_trait::async_trait]
impl rainier_middleware::Middleware for Tag {
async fn handle(&self, request: Request, next: rainier_middleware::Next) -> Response {
next.run(request).await.with_added_header("x-tag", self.0)
}
}
struct Guarded;
#[async_trait::async_trait]
impl ResourceController for Guarded {
async fn index(&self, _: Request) -> Response {
Response::text("index")
}
async fn store(&self, _: Request) -> Response {
Response::text("store")
}
async fn destroy(&self, _: Request) -> Response {
Response::text("destroy")
}
fn middleware(&self) -> ControllerMiddleware {
ControllerMiddleware::new()
.always(Tag("every"))
.only([ResourceAction::Store, ResourceAction::Destroy], Tag("writes"))
}
}
fn tags(response: &Response) -> Vec<String> {
response
.headers()
.get_all("x-tag")
.iter()
.map(|value| value.to_str().unwrap().to_string())
.collect()
}
#[tokio::test]
async fn controller_middleware_applies_only_to_the_actions_it_names() {
let mut router = Router::new();
router.resource("posts", Arc::new(Guarded));
let compiled = compiled(router);
let read = compiled.dispatch(request(Method::GET, "/posts")).await;
assert_eq!(tags(&read), vec!["every"]);
let write = compiled.dispatch(request(Method::POST, "/posts")).await;
assert_eq!(tags(&write), vec!["writes", "every"]);
}
#[test]
fn except_covers_an_action_added_later() {
let middleware = ControllerMiddleware::new().except([ResourceAction::Index], Tag("guard"));
for action in ResourceAction::ALL {
let expected = usize::from(action != ResourceAction::Index);
assert_eq!(middleware.for_action(action).len(), expected, "{action:?}");
}
}
#[test]
fn a_controller_declares_no_middleware_by_default() {
assert!(ControllerMiddleware::new().is_empty());
assert!(Posts.middleware().is_empty());
}
#[tokio::test]
async fn a_group_wraps_a_controllers_own_middleware() {
use crate::router::GroupAttributes;
let mut router = Router::new();
router.group(GroupAttributes::new().middleware(Tag("group")), |router| {
router.resource("posts", Arc::new(Guarded));
});
let response = compiled(router).dispatch(request(Method::GET, "/posts")).await;
assert_eq!(tags(&response), vec!["every", "group"]);
}
#[test]
fn registers_all_seven_routes_with_conventional_names() {
let router = posts_router();
assert_eq!(router.len(), 7);
assert_eq!(router.uri_for("posts.index"), Some("/posts"));
assert_eq!(router.uri_for("posts.create"), Some("/posts/create"));
assert_eq!(router.uri_for("posts.store"), Some("/posts"));
assert_eq!(router.uri_for("posts.show"), Some("/posts/{post}"));
assert_eq!(router.uri_for("posts.edit"), Some("/posts/{post}/edit"));
assert_eq!(router.uri_for("posts.update"), Some("/posts/{post}"));
assert_eq!(router.uri_for("posts.destroy"), Some("/posts/{post}"));
}
#[test]
fn the_parameter_is_the_singular_of_the_resource() {
let mut router = Router::new();
router.resource("categories", Arc::new(Posts));
assert_eq!(router.uri_for("categories.show"), Some("/categories/{category}"));
}
#[tokio::test]
async fn create_is_declared_before_the_parameter_route() {
let compiled = compiled(posts_router());
let response = compiled.dispatch(request(Method::GET, "/posts/create")).await;
assert_eq!(
response.status(),
StatusCode::METHOD_NOT_ALLOWED,
"should reach the unimplemented `create`, not `show`"
);
}
#[tokio::test]
async fn dispatches_each_action() {
let compiled = compiled(posts_router());
assert_eq!(body_of(compiled.dispatch(request(Method::GET, "/posts")).await).await, "index");
assert_eq!(
body_of(compiled.dispatch(request(Method::POST, "/posts")).await).await,
"store"
);
assert_eq!(
body_of(compiled.dispatch(request(Method::GET, "/posts/7")).await).await,
"show:7"
);
assert_eq!(
body_of(compiled.dispatch(request(Method::DELETE, "/posts/7")).await).await,
"destroy"
);
}
#[tokio::test]
async fn update_answers_both_put_and_patch() {
let compiled = compiled(posts_router());
for method in [Method::PUT, Method::PATCH] {
let response = compiled.dispatch(request(method.clone(), "/posts/7")).await;
assert_eq!(
response.status(),
StatusCode::METHOD_NOT_ALLOWED,
"{method} should reach the unimplemented `update` action"
);
}
}
#[tokio::test]
async fn an_unimplemented_action_is_a_405_naming_itself() {
let compiled = compiled(posts_router());
let response = compiled.dispatch(request(Method::GET, "/posts/7/edit")).await;
assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
assert!(body_of(response).await.contains("edit"));
}
#[test]
fn an_api_resource_omits_the_form_pages() {
let mut router = Router::new();
router.api_resource("posts", Arc::new(Posts));
assert_eq!(router.len(), 5);
assert_eq!(router.uri_for("posts.create"), None);
assert_eq!(router.uri_for("posts.edit"), None);
assert_eq!(router.uri_for("posts.index"), Some("/posts"));
}
#[test]
fn only_the_listed_actions_are_registered() {
let mut router = Router::new();
router.resource_actions(
"posts",
Arc::new(Posts),
&[ResourceAction::Show, ResourceAction::Index],
);
assert_eq!(router.len(), 2);
assert_eq!(router.uri_for("posts.store"), None);
assert_eq!(router.routes()[0].route_name(), Some("posts.index"));
}
#[test]
fn nested_resource_names_use_dots() {
let mut router = Router::new();
router.resource("admin/posts", Arc::new(Posts));
assert_eq!(router.uri_for("admin.posts.index"), Some("/admin/posts"));
assert_eq!(router.uri_for("admin.posts.show"), Some("/admin/posts/{post}"));
}
}