Skip to main content

rainier_framework/
binding.rs

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