Skip to main content

rustlavel_validation/
lib.rs

1//! rustlavel-validation: Laravel-style validation, written from scratch.
2//!
3//! Rules are declared the way they are in Laravel — as a string — or built with
4//! methods when the compiler should check them:
5//!
6//! ```no_run
7//! # use rustlavel_validation::{validate, Rule, Validator};
8//! # use rustlavel_http::Request;
9//! # async fn example(request: &mut Request) {
10//! let data = validate(request, &[("email", "required|email"), ("age", "integer|min:18")])
11//!     .await
12//!     .unwrap();
13//!
14//! // the same rules, checked at compile time
15//! let same = Validator::from_request(request)
16//!     .rule("email", Rule::required().email())
17//!     .rule("age", Rule::integer().min(18))
18//!     .validate();
19//! # let _ = (data, same);
20//! # }
21//! ```
22//!
23//! A failure is an [`Errors`] — field to messages — which turns into Laravel's
24//! `422` body, `{"message": "...", "errors": {"email": ["..."]}}`, for a client
25//! that wants JSON, and a plain `422` for a browser.
26//!
27//! ## Why the entry point is async
28//!
29//! Nothing here awaits yet. It is async because the rules that come next —
30//! Laravel's `unique` and `exists` — must ask the database, and this project
31//! treats a stable API as a feature. Better one `.await` today than a breaking
32//! signature change the week `rustlavel-db` lands.
33//!
34//! ## Using `?` in a handler
35//!
36//! [`Errors`] implements [`IntoResponse`], so a handler can hand one straight
37//! back. Rust's orphan rules stop this crate from also implementing that trait
38//! for `Result<_, Errors>` — `Result` belongs to `core` and the trait belongs to
39//! `rustlavel-http` — so [`attempt`] bridges the gap and lets the body of a
40//! handler use `?` as normal:
41//!
42//! ```no_run
43//! # use rustlavel_validation::{attempt, validate};
44//! # use rustlavel_http::{IntoResponse, Request, Response};
45//! async fn store(mut request: Request) -> impl IntoResponse {
46//!     attempt(async move {
47//!         let data = validate(&mut request, &[("email", "required|email")]).await?;
48//!         Ok(Response::json(data.into_json()))
49//!     })
50//!     .await
51//! }
52//! ```
53
54pub mod check;
55pub mod errors;
56pub mod input;
57pub mod messages;
58pub mod rule;
59pub mod validator;
60
61pub use errors::Errors;
62pub use input::Input;
63pub use messages::{Messages, SizeKind};
64pub use rule::{IntoRules, Rule, Rules};
65pub use validator::{Validated, Validator};
66
67use rustlavel_http::{IntoResponse, Request, Response};
68use std::future::Future;
69
70/// Validate a request against Laravel-style rule strings.
71///
72/// The shortest path from a request to trusted data: on success the validated
73/// subset comes back, on failure an [`Errors`] that already knows whether the
74/// client wanted JSON.
75///
76/// # Panics
77///
78/// If a rule spec is malformed. A bad spec is a bug in the source rather than
79/// bad input from a user, and the panic carries the parser's message — which
80/// names the rule and suggests the one that was meant. Use [`Rules::parse`]
81/// when the spec comes from data instead of from code.
82pub async fn validate(
83    request: &mut Request,
84    rules: &[(&str, &str)],
85) -> Result<Validated, Errors> {
86    Validator::from_request(request).rules(rules).validate()
87}
88
89/// Run a handler body that uses `?`, turning a validation failure into its
90/// `422` response instead of letting it escape as a server error.
91///
92/// See the crate documentation for why this exists rather than an
93/// `IntoResponse` implementation on `Result<_, Errors>`.
94pub async fn attempt<T: IntoResponse>(
95    body: impl Future<Output = Result<T, Errors>>,
96) -> Response {
97    match body.await {
98        Ok(value) => value.into_response(),
99        Err(errors) => errors.into_response(),
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use rustlavel_core::Json;
107    use rustlavel_http::{Method, Status};
108    use std::task::{Context, Poll};
109
110    /// Drive a future to completion without a runtime.
111    ///
112    /// Validation never awaits I/O, so one poll always finishes it; pulling in
113    /// an executor to prove that would only slow the test suite down.
114    fn block_on<F: Future>(future: F) -> F::Output {
115        let mut context = Context::from_waker(std::task::Waker::noop());
116        let mut future = std::pin::pin!(future);
117        match future.as_mut().poll(&mut context) {
118            Poll::Ready(output) => output,
119            Poll::Pending => panic!("a validation future must never need a runtime"),
120        }
121    }
122
123    #[test]
124    fn the_entry_point_returns_the_validated_subset_of_a_request() {
125        let mut request = Request::new(Method::Post, "/users")
126            .with_json(Json::object([("email", "ada@example.com".into()), ("age", 36.into())]));
127
128        let data = block_on(validate(
129            &mut request,
130            &[("email", "required|email"), ("age", "integer|min:18")],
131        ))
132        .unwrap();
133
134        assert_eq!(data.string("email").as_deref(), Some("ada@example.com"));
135        assert_eq!(data.integer("age"), Some(36));
136    }
137
138    #[test]
139    fn the_entry_point_returns_errors_shaped_like_laravels_422() {
140        let mut request = Request::new(Method::Post, "/users")
141            .with_json(Json::object([("email", "nope".into()), ("age", 12.into())]));
142
143        let errors = block_on(validate(
144            &mut request,
145            &[("email", "required|email"), ("age", "integer|min:18")],
146        ))
147        .unwrap_err();
148
149        assert_eq!(
150            errors.to_json().to_string(),
151            r#"{"errors":{"age":["The age field must be at least 18."],"email":["The email field must be a valid email address."]},"message":"The age field must be at least 18. (and 1 more error)"}"#
152        );
153    }
154
155    #[test]
156    fn a_handler_body_using_the_question_mark_answers_422() {
157        let mut request = Request::new(Method::Post, "/api/users")
158            .with_json(Json::object([("email", "nope".into())]));
159
160        let response = block_on(attempt(async move {
161            let data = validate(&mut request, &[("email", "required|email")]).await?;
162            Ok(Response::json(data.into_json()))
163        }));
164
165        assert_eq!(response.status, Status::UNPROCESSABLE);
166        assert_eq!(response.headers.content_type(), Some("application/json"));
167        assert!(response.body_string().contains(r#""errors":{"email":["#));
168    }
169
170    #[test]
171    fn a_handler_body_that_validates_answers_with_its_own_response() {
172        let mut request = Request::new(Method::Post, "/api/users")
173            .with_json(Json::object([("email", "ada@example.com".into())]));
174
175        let response = block_on(attempt(async move {
176            let data = validate(&mut request, &[("email", "required|email")]).await?;
177            Ok(Response::json(data.into_json()))
178        }));
179
180        assert_eq!(response.status, Status::OK);
181        assert_eq!(response.body_string(), r#"{"email":"ada@example.com"}"#);
182    }
183
184    #[test]
185    fn a_browser_submission_fails_with_a_plain_422() {
186        let mut request = Request::new(Method::Post, "/register").with_form(&[("email", "nope")]);
187
188        let response = block_on(attempt(async move {
189            let data = validate(&mut request, &[("email", "required|email")]).await?;
190            Ok(Response::json(data.into_json()))
191        }));
192
193        assert_eq!(response.status, Status::UNPROCESSABLE);
194        assert_eq!(response.headers.content_type(), Some("text/plain"));
195        assert_eq!(response.body_string(), "The email field must be a valid email address.");
196    }
197}