Skip to main content

rainier_validation/
form_request.rs

1//! Request contracts — [`FormRequest`] and the [`Validated`] extractor.
2//!
3//! A form request bundles three things a controller would otherwise do
4//! by hand: **authorise** the caller, **validate** the input, and hand the
5//! action a payload it can trust — an actual typed struct rather than a
6//! loose map:
7//!
8//! ```
9//! use rainier_validation::{FormRequest, Rule, RuleSet};
10//! use rainier_http::Request;
11//! use serde::Deserialize;
12//!
13//! #[derive(Deserialize)]
14//! struct StorePost {
15//!     title: String,
16//!     body: String,
17//!     published: Option<bool>,
18//! }
19//!
20//! #[async_trait::async_trait]
21//! impl FormRequest for StorePost {
22//!     fn rules() -> RuleSet {
23//!         vec![
24//!             ("title", vec![Rule::Required, Rule::String, Rule::Between(3.0, 120.0)]),
25//!             ("body", vec![Rule::Required, Rule::String]),
26//!             ("published", vec![Rule::Boolean]),
27//!         ]
28//!     }
29//!
30//!     async fn authorize(request: &Request) -> bool {
31//!         request.bearer_token().is_some()
32//!     }
33//! }
34//! ```
35//!
36//! A handler then takes `Validated<StorePost>` and receives a struct that is
37//! authorised, validated, and free of any field the contract did not declare.
38//!
39//! ## Why the payload is filtered
40//!
41//! [`Validated`] deserialises from the *validated subset* of the input, not
42//! from everything the client sent. A field with no rule never reaches the
43//! struct — mass-assignment protection, obtained for free from the rules you
44//! already wrote.
45
46use std::sync::Arc;
47
48use rainier_http::coerce;
49use rainier_http::{FromRequest, Request};
50use rainier_support::{BoxedFuture, Error, Result};
51use serde::de::DeserializeOwned;
52use serde_json::Value;
53
54use crate::rule::Rule;
55use crate::validator::{RuleSet, ValidationErrors, Validator};
56
57/// A typed, authorised, validated request payload.
58#[async_trait::async_trait]
59pub trait FormRequest: DeserializeOwned + Send + Sync + 'static {
60    /// The validation rules, by field.
61    fn rules() -> RuleSet;
62
63    /// Whether the caller may make this request at all.
64    ///
65    /// Runs **before** validation, so an unauthorised caller cannot use
66    /// validation messages to probe what the endpoint expects. Returning
67    /// `false` produces a `403`.
68    ///
69    /// `async` because a real authorisation check reads a policy, a role, or
70    /// the record being modified.
71    async fn authorize(request: &Request) -> bool {
72        let _ = request;
73        true
74    }
75
76    /// Messages overriding the defaults, keyed `"field.rule"`.
77    fn messages() -> Vec<(&'static str, &'static str)> {
78        Vec::new()
79    }
80
81    /// What to validate. Defaults to the merged query string and body.
82    ///
83    /// Override to fold in route parameters — an update contract that needs
84    /// `{post}` to check uniqueness-except-self, for instance.
85    fn validation_data(request: &Request) -> Value {
86        request.all()
87    }
88
89    /// Build the payload from the validated input.
90    ///
91    /// The default deserialises with string coercion, so a urlencoded form
92    /// fills a `u32` field. Override for a payload that is not a plain
93    /// deserialisation of the input.
94    fn from_input(input: Value) -> Result<Self> {
95        coerce::from_value(&input).map_err(|e| {
96            // The rules passed but the struct did not fit, which means the two
97            // disagree — a programming error, not a client one.
98            Error::internal(format!(
99                "`{}` passed validation but could not be built from the input: {e}",
100                std::any::type_name::<Self>()
101            ))
102        })
103    }
104
105    /// Run authorisation and validation, and build the payload.
106    ///
107    /// The whole contract in one call, so it can be used outside an extractor
108    /// — from a console command, or a test.
109    async fn validate_request(request: &Request) -> Result<Self> {
110        if !Self::authorize(request).await {
111            return Err(Error::unauthorized("This action is unauthorized."));
112        }
113
114        let input = Self::validation_data(request);
115        let validator = Self::validator();
116
117        let validated = validator.validated_only(&input).map_err(Error::from)?;
118        Self::from_input(validated)
119    }
120
121    /// The configured [`Validator`] for this contract.
122    fn validator() -> Validator {
123        Validator::from_rules(Self::rules()).custom_messages(Self::messages())
124    }
125
126    /// Validate without building the payload, returning the failures.
127    fn check(request: &Request) -> std::result::Result<(), ValidationErrors> {
128        Self::validator().validate(&Self::validation_data(request))
129    }
130}
131
132/// The extractor that runs a [`FormRequest`].
133///
134/// ```ignore
135/// async fn store(Validated(post): Validated<StorePost>) -> Response {
136///     Response::text(post.title)     // authorised, validated, filtered
137/// }
138/// ```
139#[derive(Debug, Clone, Copy, Default)]
140pub struct Validated<T>(pub T);
141
142impl<T> Validated<T> {
143    /// Unwrap the payload.
144    pub fn into_inner(self) -> T {
145        self.0
146    }
147}
148
149impl<T> std::ops::Deref for Validated<T> {
150    type Target = T;
151
152    fn deref(&self) -> &T {
153        &self.0
154    }
155}
156
157impl<T: FormRequest> FromRequest for Validated<T> {
158    fn from_request(request: Arc<Request>) -> BoxedFuture<Result<Self>> {
159        Box::pin(async move { T::validate_request(&request).await.map(Validated) })
160    }
161}
162
163/// Sugar for building a [`RuleSet`] entry.
164///
165/// ```
166/// # use rainier_validation::{field, Rule, RuleSet};
167/// let rules: RuleSet = vec![
168///     field("title", [Rule::Required, Rule::String]),
169///     field("body", [Rule::Required]),
170/// ];
171/// assert_eq!(rules.len(), 2);
172/// ```
173pub fn field(
174    name: &'static str,
175    rules: impl IntoIterator<Item = Rule>,
176) -> (&'static str, Vec<Rule>) {
177    (name, rules.into_iter().collect())
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use rainier_http::Method;
184    use serde::Deserialize;
185    use serde_json::json;
186
187    #[derive(Debug, Deserialize, PartialEq)]
188    struct StorePost {
189        title: String,
190        body: String,
191        #[serde(default)]
192        published: Option<bool>,
193    }
194
195    #[async_trait::async_trait]
196    impl FormRequest for StorePost {
197        fn rules() -> RuleSet {
198            vec![
199                field("title", [Rule::Required, Rule::String, Rule::Between(3.0, 120.0)]),
200                field("body", [Rule::Required, Rule::String]),
201                field("published", [Rule::Boolean]),
202            ]
203        }
204    }
205
206    fn post(body: Value) -> Request {
207        Request::builder().method(Method::POST).json(&body).build()
208    }
209
210    #[tokio::test]
211    async fn a_valid_request_becomes_a_typed_payload() {
212        let request = post(json!({ "title": "Hello", "body": "World", "published": true }));
213        let payload = StorePost::validate_request(&request).await.unwrap();
214
215        assert_eq!(
216            payload,
217            StorePost { title: "Hello".into(), body: "World".into(), published: Some(true) }
218        );
219    }
220
221    #[tokio::test]
222    async fn an_invalid_request_is_a_422_with_the_failures() {
223        let request = post(json!({ "title": "Hi" }));
224        let err = StorePost::validate_request(&request).await.unwrap_err();
225
226        assert_eq!(err.status(), 422);
227        let details = err.details().unwrap();
228        assert!(details["title"][0].as_str().unwrap().contains("between"));
229        assert!(details["body"][0].as_str().unwrap().contains("required"));
230    }
231
232    #[tokio::test]
233    async fn undeclared_fields_never_reach_the_payload() {
234        #[derive(Debug, Deserialize)]
235        struct Partial {
236            title: String,
237            #[serde(default)]
238            is_admin: bool,
239        }
240
241        #[async_trait::async_trait]
242        impl FormRequest for Partial {
243            fn rules() -> RuleSet {
244                vec![field("title", [Rule::Required])]
245            }
246        }
247
248        let request = post(json!({ "title": "Hello", "is_admin": true }));
249        let payload = Partial::validate_request(&request).await.unwrap();
250
251        assert_eq!(payload.title, "Hello");
252        assert!(!payload.is_admin, "a field with no rule must not be mass-assignable");
253    }
254
255    #[tokio::test]
256    async fn authorisation_runs_before_validation() {
257        #[derive(Debug, Deserialize)]
258        struct Guarded {
259            #[allow(dead_code)]
260            title: String,
261        }
262
263        #[async_trait::async_trait]
264        impl FormRequest for Guarded {
265            fn rules() -> RuleSet {
266                vec![field("title", [Rule::Required])]
267            }
268            async fn authorize(request: &Request) -> bool {
269                request.bearer_token() == Some("secret")
270            }
271        }
272
273        // Invalid *and* unauthorised: the 403 must win, so validation messages
274        // cannot be used to probe the endpoint's shape.
275        let denied = Guarded::validate_request(&post(json!({}))).await.unwrap_err();
276        assert_eq!(denied.status(), 403);
277
278        let allowed = Request::builder()
279            .method(Method::POST)
280            .header("authorization", "Bearer secret")
281            .json(&json!({ "title": "ok" }))
282            .build();
283        assert!(Guarded::validate_request(&allowed).await.is_ok());
284    }
285
286    #[tokio::test]
287    async fn custom_messages_are_applied() {
288        #[derive(Debug, Deserialize)]
289        struct Custom {
290            #[allow(dead_code)]
291            email: String,
292        }
293
294        #[async_trait::async_trait]
295        impl FormRequest for Custom {
296            fn rules() -> RuleSet {
297                vec![field("email", [Rule::Required, Rule::Email])]
298            }
299            fn messages() -> Vec<(&'static str, &'static str)> {
300                vec![("email.required", "We really need your email.")]
301            }
302        }
303
304        let err = Custom::validate_request(&post(json!({}))).await.unwrap_err();
305        assert_eq!(err.details().unwrap()["email"][0], "We really need your email.");
306    }
307
308    #[tokio::test]
309    async fn validation_data_can_fold_in_route_parameters() {
310        #[derive(Debug, Deserialize)]
311        struct UpdatePost {
312            id: u64,
313            #[allow(dead_code)]
314            title: String,
315        }
316
317        #[async_trait::async_trait]
318        impl FormRequest for UpdatePost {
319            fn rules() -> RuleSet {
320                vec![field("id", [Rule::Required, Rule::Integer]), field("title", [Rule::Required])]
321            }
322            fn validation_data(request: &Request) -> Value {
323                let mut data = request.all();
324                if let (Some(object), Some(id)) =
325                    (data.as_object_mut(), request.route_param("post"))
326                {
327                    object.insert("id".into(), Value::String(id.to_string()));
328                }
329                data
330            }
331        }
332
333        let request = Request::builder()
334            .method(Method::POST)
335            .route_param("post", "42")
336            .json(&json!({ "title": "Edited" }))
337            .build();
338
339        let payload = UpdatePost::validate_request(&request).await.unwrap();
340        assert_eq!(payload.id, 42);
341    }
342
343    #[tokio::test]
344    async fn coercion_lets_a_form_body_fill_typed_fields() {
345        #[derive(Debug, Deserialize)]
346        struct Paged {
347            page: u32,
348            active: bool,
349        }
350
351        #[async_trait::async_trait]
352        impl FormRequest for Paged {
353            fn rules() -> RuleSet {
354                vec![
355                    field("page", [Rule::Required, Rule::Integer]),
356                    field("active", [Rule::Required, Rule::Boolean]),
357                ]
358            }
359        }
360
361        let request =
362            Request::builder().method(Method::POST).form(&[("page", "3"), ("active", "1")]).build();
363
364        let payload = Paged::validate_request(&request).await.unwrap();
365        assert_eq!(payload.page, 3);
366        assert!(payload.active);
367    }
368
369    #[tokio::test]
370    async fn the_extractor_wires_it_into_a_handler_signature() {
371        let request = Arc::new(post(json!({ "title": "Hello", "body": "World" })));
372        let Validated(payload) = Validated::<StorePost>::from_request(request).await.unwrap();
373        assert_eq!(payload.title, "Hello");
374    }
375
376    #[tokio::test]
377    async fn the_extractor_surfaces_the_422() {
378        let request = Arc::new(post(json!({})));
379        let err = Validated::<StorePost>::from_request(request).await.unwrap_err();
380        assert_eq!(err.status(), 422);
381    }
382
383    #[tokio::test]
384    async fn check_validates_without_building_the_payload() {
385        let errors = StorePost::check(&post(json!({ "title": "Hi" }))).unwrap_err();
386        assert!(errors.has("title"));
387        assert!(errors.has("body"));
388
389        assert!(StorePost::check(&post(json!({ "title": "Hello", "body": "x" }))).is_ok());
390    }
391
392    #[tokio::test]
393    async fn a_payload_that_disagrees_with_its_rules_is_an_internal_error() {
394        // `title` is required by the rules but the struct wants a `u64`, so
395        // the two disagree — that is the framework author's bug, not the
396        // client's, and it must not be reported as a 422.
397        #[derive(Debug, Deserialize)]
398        struct Mismatched {
399            #[allow(dead_code)]
400            title: u64,
401        }
402
403        #[async_trait::async_trait]
404        impl FormRequest for Mismatched {
405            fn rules() -> RuleSet {
406                vec![field("title", [Rule::Required, Rule::String])]
407            }
408        }
409
410        let err = Mismatched::validate_request(&post(json!({ "title": "not a number" })))
411            .await
412            .unwrap_err();
413        assert_eq!(err.status(), 500);
414        assert!(err.message().contains("passed validation"), "{}", err.message());
415    }
416}