rainier_framework/binding.rs
1//! Route-model binding — Laravel's `public function show(Post $post)`.
2//!
3//! ```ignore
4//! // routes/api.rs
5//! router.get("/posts/{post}", show).where_slug("post");
6//!
7//! // app/http/controllers/post_controller.rs
8//! pub async fn show(Bound(post): Bound<Post>) -> Result<Response> {
9//! Ok(Response::json(&post))
10//! }
11//! ```
12//!
13//! The action receives the **model**, not the id: the lookup, the 404 and the
14//! `?` are all gone from the body, and what is left is the thing the action is
15//! actually about.
16//!
17//! # Why it lives here
18//!
19//! It needs a request, a container and a repository at once, and
20//! [`rainier-database`](rainier_database) is not allowed to know an HTTP
21//! request exists — that rule is what keeps a repository usable from a queue
22//! worker with no router in it. This crate is the one that already has both
23//! halves, the same reason
24//! [`DatabaseChannel`](crate::notifications::DatabaseChannel) is here.
25
26use std::marker::PhantomData;
27use std::sync::Arc;
28
29use rainier_database::{Database, EntityRepository, Model, Repository};
30use rainier_http::{FromRequest, Request};
31use rainier_support::{str::snake, BoxedFuture, Error, Result};
32
33/// A model resolved from a route parameter.
34///
35/// Looks the row up by the model's
36/// [route key](rainier_database::Model::route_key_name) — the primary key
37/// unless the model says otherwise, which is how `/posts/{post}` binds by slug
38/// while `/users/{user}` binds by id.
39///
40/// # Which parameter
41///
42/// The one named after the model, lower-cased: `Post` reads `{post}`. Laravel's
43/// convention, and it is worth keeping to — a route whose parameter is named
44/// something else should say so with [`BoundAs`], not leave the reader
45/// guessing which of two parameters was meant.
46///
47/// # Failures
48///
49/// - no such parameter on the route → a `500`, because that is a wiring
50/// mistake rather than anything the caller did;
51/// - no such row → a `404` naming the model, exactly as
52/// [`find_or_fail`](rainier_database::Repository::find_or_fail) does.
53///
54/// # It does not authorise
55///
56/// Binding finds the row; it does not ask whether this caller may have it.
57/// A draft post binds happily for a stranger. Follow it with a
58/// [policy](rainier_auth::Gate) — or, better, with a
59/// [request contract](rainier_validation::FormRequest) that authorises before
60/// the action runs at all.
61pub struct Bound<M: Model>(pub M);
62
63impl<M: Model> Bound<M> {
64 /// The model.
65 pub fn into_inner(self) -> M {
66 self.0
67 }
68}
69
70impl<M: Model> std::ops::Deref for Bound<M> {
71 type Target = M;
72
73 fn deref(&self) -> &M {
74 &self.0
75 }
76}
77
78impl<M: Model> FromRequest for Bound<M> {
79 fn from_request(request: Arc<Request>) -> BoxedFuture<Result<Self>> {
80 Box::pin(async move { resolve::<M>(&request, &snake(M::model_name())).await.map(Bound) })
81 }
82}
83
84/// A model resolved from a **named** route parameter.
85///
86/// For the route with two of them, where the convention cannot say which:
87///
88/// ```ignore
89/// router.get("/posts/{post}/comments/{comment}", show);
90///
91/// pub async fn show(
92/// post: BoundAs<Post, PostParam>,
93/// comment: BoundAs<Comment, CommentParam>,
94/// ) -> Result<Response> {
95/// Ok(Response::json(&comment.into_inner()))
96/// }
97/// ```
98///
99/// The parameter name is a type, because an extractor's configuration has to
100/// live in its type — there is nowhere else for it to come from. Declare one
101/// with [`param_key!`](crate::param_key).
102pub struct BoundAs<M: Model, K: ParamKey>(pub M, PhantomData<fn() -> K>);
103
104impl<M: Model, K: ParamKey> BoundAs<M, K> {
105 /// The model.
106 pub fn into_inner(self) -> M {
107 self.0
108 }
109}
110
111impl<M: Model, K: ParamKey> std::ops::Deref for BoundAs<M, K> {
112 type Target = M;
113
114 fn deref(&self) -> &M {
115 &self.0
116 }
117}
118
119impl<M: Model, K: ParamKey> FromRequest for BoundAs<M, K> {
120 fn from_request(request: Arc<Request>) -> BoxedFuture<Result<Self>> {
121 Box::pin(async move {
122 resolve::<M>(&request, K::NAME).await.map(|model| BoundAs(model, PhantomData))
123 })
124 }
125}
126
127/// The name of a route parameter, as a type. See [`param_key!`](crate::param_key).
128pub trait ParamKey: Send + Sync + 'static {
129 /// The parameter's name.
130 const NAME: &'static str;
131}
132
133/// Declare a route-parameter name for [`BoundAs`].
134///
135/// ```ignore
136/// param_key!(pub AuthorParam = "author");
137///
138/// pub async fn show(author: BoundAs<User, AuthorParam>) -> Result<Response> {
139/// Ok(Response::json(&author.into_inner()))
140/// }
141/// ```
142#[macro_export]
143macro_rules! param_key {
144 ($(#[$meta:meta])* $vis:vis $name:ident = $param:literal) => {
145 $(#[$meta])*
146 #[derive(Debug, Clone, Copy)]
147 $vis struct $name;
148
149 impl $crate::binding::ParamKey for $name {
150 const NAME: &'static str = $param;
151 }
152 };
153}
154
155/// Look one model up by the route parameter `name`.
156async fn resolve<M: Model>(request: &Request, name: &str) -> Result<M> {
157 let Some(value) = request.route_param(name) else {
158 // Not the caller's doing: the route does not have this parameter, or
159 // it is spelled differently. Saying which is the difference between a
160 // one-minute fix and an afternoon.
161 return Err(Error::internal(format!(
162 "the route has no `{{{name}}}` parameter to bind a {} from",
163 M::model_name()
164 )));
165 };
166
167 let database = rainier_container::facade_application().resolve::<Database>()?;
168 let repository = EntityRepository::<M>::new((*database).clone());
169
170 repository
171 .first_by(M::route_key_name(), value.into())
172 .await?
173 .ok_or_else(|| Error::not_found(format!("No {} matches the given key.", M::model_name())))
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 param_key!(
181 /// The `author` parameter.
182 pub AuthorParam = "author"
183 );
184
185 #[test]
186 fn a_param_key_carries_its_name() {
187 assert_eq!(AuthorParam::NAME, "author");
188 }
189
190 #[test]
191 fn the_conventional_parameter_is_the_model_name_lower_cased() {
192 // `Post` reads `{post}`, which is the convention a reader assumes.
193 assert_eq!(snake("Post"), "post");
194 assert_eq!(snake("BlogPost"), "blog_post");
195 }
196
197 #[tokio::test]
198 async fn a_route_with_no_such_parameter_is_a_wiring_error_not_a_404() {
199 #[derive(Debug, Clone, PartialEq, rainier_orm::Entity)]
200 #[orm(table = "posts")]
201 struct Post {
202 #[orm(pk)]
203 id: u64,
204 }
205 impl Model for Post {}
206
207 let request = Request::builder().build();
208 let err = resolve::<Post>(&request, "post").await.unwrap_err();
209
210 assert_eq!(err.status(), 500, "{}", err.message());
211 assert!(err.message().contains("{post}"), "{}", err.message());
212 assert!(err.message().contains("Post"), "{}", err.message());
213 }
214}