|
| 1 | +--- |
| 2 | +title: Form Validation |
| 3 | +slug: form-validation |
| 4 | +--- |
| 5 | + |
| 6 | +> [!tip] |
| 7 | +> - To validate user inputs, we use [Garde](https://docs.rs/garde/latest/garde/) and a custom extractor `ValidatedJson`, which we build by implementing Axum's [`FromRequest`](https://docs.rs/axum/latest/axum/extract/trait.FromRequest.html) trait, to improve developers' productivity. |
| 8 | +> - Later, we customize Garde's default validation error messages with its [`I18n`](https://docs.rs/garde/latest/garde/#i18n) trait, which even can be used to support different languages. |
| 9 | +
|
| 10 | +## Add `garde` |
| 11 | + |
| 12 | +Run following `cargo add` command from the `book_service` folder. |
| 13 | + |
| 14 | +```rust |
| 15 | +cargo add garde -F derive,url |
| 16 | +``` |
| 17 | + |
| 18 | +## Add Form Validation |
| 19 | + |
| 20 | +### Add Validation Rules |
| 21 | + |
| 22 | +Update `book_service/src/app/book/payload.rs`. |
| 23 | + |
| 24 | +```rust |
| 25 | +use garde::Validate; |
| 26 | +use jiff::civil::Date; |
| 27 | +use serde::Deserialize; |
| 28 | + |
| 29 | +use crate::models::BookStatus; |
| 30 | + |
| 31 | +#[derive(Debug, Deserialize, Validate)] |
| 32 | +pub struct BookRequest { |
| 33 | + #[garde(length(min = 1, max = 255))] |
| 34 | + pub title: String, |
| 35 | + |
| 36 | + #[garde(skip)] |
| 37 | + pub description: Option<String>, |
| 38 | + |
| 39 | + #[garde(url)] |
| 40 | + pub image_url: Option<String>, |
| 41 | + |
| 42 | + #[garde(skip)] |
| 43 | + pub published_date: Date, |
| 44 | + |
| 45 | + #[garde(skip)] |
| 46 | + pub status: BookStatus, |
| 47 | +} |
| 48 | +``` |
| 49 | + |
| 50 | +### Add `ValidatedJson` Extractor |
| 51 | + |
| 52 | +Add a new file `book_service/src/app/shared/validation.rs`. |
| 53 | + |
| 54 | +```rust |
| 55 | +use std::collections::HashMap; |
| 56 | + |
| 57 | +use axum::{ |
| 58 | + Json, |
| 59 | + extract::{FromRequest, Request, rejection::JsonRejection}, |
| 60 | + http::StatusCode, |
| 61 | + response::{IntoResponse, Response}, |
| 62 | +}; |
| 63 | +use garde::{Report, Validate}; |
| 64 | +use serde::{Serialize, de::DeserializeOwned}; |
| 65 | + |
| 66 | +#[derive(Debug, Clone, Copy, Default)] |
| 67 | +pub struct ValidatedJson<T>(pub T); |
| 68 | + |
| 69 | +impl<S, T> FromRequest<S> for ValidatedJson<T> |
| 70 | +where |
| 71 | + T: DeserializeOwned + Validate<Context = ()>, |
| 72 | + S: Send + Sync, |
| 73 | + Json<T>: FromRequest<S, Rejection = JsonRejection>, |
| 74 | +{ |
| 75 | + type Rejection = ServerError; |
| 76 | + |
| 77 | + async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> { |
| 78 | + let Json(value) = Json::<T>::from_request(req, state).await?; |
| 79 | + value.validate()?; |
| 80 | + Ok(ValidatedJson(value)) |
| 81 | + } |
| 82 | +} |
| 83 | + |
| 84 | +#[derive(Serialize)] |
| 85 | +pub struct ValidationErrorResponse { |
| 86 | + pub errors: HashMap<String, String>, |
| 87 | +} |
| 88 | + |
| 89 | +#[derive(Debug)] |
| 90 | +pub enum ServerError { |
| 91 | + ValidationError(Report), |
| 92 | + AxumJsonRejection(JsonRejection), |
| 93 | +} |
| 94 | + |
| 95 | +impl From<Report> for ServerError { |
| 96 | + fn from(err: Report) -> Self { |
| 97 | + Self::ValidationError(err) |
| 98 | + } |
| 99 | +} |
| 100 | + |
| 101 | +impl From<JsonRejection> for ServerError { |
| 102 | + fn from(err: JsonRejection) -> Self { |
| 103 | + Self::AxumJsonRejection(err) |
| 104 | + } |
| 105 | +} |
| 106 | + |
| 107 | +impl IntoResponse for ServerError { |
| 108 | + fn into_response(self) -> Response { |
| 109 | + match self { |
| 110 | + Self::ValidationError(report) => { |
| 111 | + let errors = report |
| 112 | + .iter() |
| 113 | + .map(|(path, error)| (path.to_string(), error.message().to_string())) |
| 114 | + .collect::<HashMap<_, _>>(); |
| 115 | + |
| 116 | + (StatusCode::UNPROCESSABLE_ENTITY, Json(ValidationErrorResponse { errors })) |
| 117 | + .into_response() |
| 118 | + } |
| 119 | + Self::AxumJsonRejection(rejection) => { |
| 120 | + (StatusCode::BAD_REQUEST, rejection).into_response() |
| 121 | + } |
| 122 | + } |
| 123 | + } |
| 124 | +} |
| 125 | +``` |
| 126 | + |
| 127 | +Update `book_service/src/app/shared/mod.rs`. |
| 128 | + |
| 129 | +```rust |
| 130 | +mod pagination; |
| 131 | +mod validation; |
| 132 | + |
| 133 | +pub use pagination::Pagination; |
| 134 | +pub use validation::ValidatedJson; |
| 135 | +``` |
| 136 | + |
| 137 | +### Use New Extractor on Handlers |
| 138 | + |
| 139 | +Update `book_service/src/app/book/handler.rs`. |
| 140 | + |
| 141 | +```rust |
| 142 | +// ... |
| 143 | +use super::payload::BookRequest; |
| 144 | +use crate::{ |
| 145 | + app::shared::{Pagination, ValidatedJson}, |
| 146 | + errors::Error, |
| 147 | + models::Book, |
| 148 | + state::AppState, |
| 149 | +}; |
| 150 | + |
| 151 | +// ... |
| 152 | + |
| 153 | +pub async fn create( |
| 154 | + State(mut state): State<AppState>, |
| 155 | + ValidatedJson(payload): ValidatedJson<BookRequest>, |
| 156 | +) -> Result<impl IntoResponse, Error> { |
| 157 | +// ... |
| 158 | + |
| 159 | +pub async fn update( |
| 160 | + State(mut state): State<AppState>, |
| 161 | + Path(id): Path<Uuid>, |
| 162 | + ValidatedJson(payload): ValidatedJson<BookRequest>, |
| 163 | +) -> Result<impl IntoResponse, Error> { |
| 164 | +// ... |
| 165 | +``` |
| 166 | + |
| 167 | +> [!tip] |
| 168 | +> This generates an error response similar to the following JSON response, which uses Garde's default error messages via its default implementation, [`i18n::DefaultI18n`](https://github.com/jprochazk/garde/blob/c67cda4573fee55865811eefc7fb7f22a692336c/garde/src/i18n.rs#L314) |
| 169 | +> ```json |
| 170 | +> { |
| 171 | +> "errors": { |
| 172 | +> "title": "length is lower than 1", |
| 173 | +> "image_url": "not a valid url: relative URL without a base" |
| 174 | +> } |
| 175 | +> } |
| 176 | +> ``` |
| 177 | +
|
| 178 | +## Customize Default Error Messages |
| 179 | +
|
| 180 | +> [!tip] |
| 181 | +> We refer above default implementation, use our own format for messages, and [use the `garde::i18n::with_i18n`](https://docs.rs/garde/latest/garde/index.html#i18n) function to customize validation error messages via a custom `I18n` handler. |
| 182 | +> ```json |
| 183 | +> { |
| 184 | +> "errors": { |
| 185 | +> "title": "Must be at least 1 character long", |
| 186 | +> "image_url": "Must be a valid URL" |
| 187 | +> } |
| 188 | +> } |
| 189 | +> ``` |
| 190 | +
|
| 191 | +Update `book_service/src/app/shared/validation.rs`. |
| 192 | +
|
| 193 | +```rust |
| 194 | +use std::{borrow::Cow, collections::HashMap, fmt::Display}; |
| 195 | +// ... |
| 196 | +use garde::{ |
| 197 | + Report, Validate, |
| 198 | + i18n::{ |
| 199 | + I18n, InvalidCreditCard, InvalidEmail, InvalidPhoneNumber, InvalidUrl, IpKind, with_i18n, |
| 200 | + }, |
| 201 | +}; |
| 202 | +// ... |
| 203 | +
|
| 204 | +impl<S, T> FromRequest<S> for ValidatedJson<T> |
| 205 | +where |
| 206 | + T: DeserializeOwned + Validate<Context = ()>, |
| 207 | + S: Send + Sync, |
| 208 | + Json<T>: FromRequest<S, Rejection = JsonRejection>, |
| 209 | +{ |
| 210 | + type Rejection = ServerError; |
| 211 | +
|
| 212 | + async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> { |
| 213 | + let Json(value) = Json::<T>::from_request(req, state).await?; |
| 214 | + with_i18n(English, || value.validate())?; |
| 215 | + Ok(ValidatedJson(value)) |
| 216 | + } |
| 217 | +} |
| 218 | +
|
| 219 | +// ... |
| 220 | +
|
| 221 | +struct English; |
| 222 | +
|
| 223 | +impl I18n for English { |
| 224 | + fn length_lower_than(&self, min: usize) -> Cow<'static, str> { |
| 225 | + match min { |
| 226 | + 1 => Cow::Borrowed("Must be at least 1 character long"), |
| 227 | + _ => format!("Must be at least {min} characters long").into(), |
| 228 | + } |
| 229 | + } |
| 230 | +
|
| 231 | + fn length_greater_than(&self, max: usize) -> Cow<'static, str> { |
| 232 | + match max { |
| 233 | + 1 => Cow::Borrowed("Must not exceed 1 character"), |
| 234 | + _ => format!("Must not exceed {max} characters").into(), |
| 235 | + } |
| 236 | + } |
| 237 | +
|
| 238 | + fn range_lower_than(&self, min: &dyn Display) -> Cow<'static, str> { |
| 239 | + format!("Must be greater than or equal to {min}").into() |
| 240 | + } |
| 241 | +
|
| 242 | + fn range_greater_than(&self, max: &dyn Display) -> Cow<'static, str> { |
| 243 | + format!("Must be less than or equal to {max}").into() |
| 244 | + } |
| 245 | +
|
| 246 | + fn credit_card_invalid(&self, _reason: InvalidCreditCard) -> Cow<'static, str> { |
| 247 | + Cow::Borrowed("Must be a valid credit card number") |
| 248 | + } |
| 249 | +
|
| 250 | + fn pattern_no_match(&self, _pattern: &dyn Display) -> Cow<'static, str> { |
| 251 | + Cow::Borrowed("Must match the required format") |
| 252 | + } |
| 253 | +
|
| 254 | + fn contains_missing(&self, pattern: &dyn Display) -> Cow<'static, str> { |
| 255 | + format!("Must contain \"{pattern}\"").into() |
| 256 | + } |
| 257 | +
|
| 258 | + fn url_invalid(&self, _reason: InvalidUrl) -> Cow<'static, str> { |
| 259 | + Cow::Borrowed("Must be a valid URL") |
| 260 | + } |
| 261 | +
|
| 262 | + fn prefix_missing(&self, pattern: &dyn Display) -> Cow<'static, str> { |
| 263 | + format!("Must start with \"{pattern}\"").into() |
| 264 | + } |
| 265 | +
|
| 266 | + fn suffix_missing(&self, pattern: &dyn Display) -> Cow<'static, str> { |
| 267 | + format!("Must end with \"{pattern}\"").into() |
| 268 | + } |
| 269 | +
|
| 270 | + fn phone_number_invalid(&self, _reason: InvalidPhoneNumber) -> Cow<'static, str> { |
| 271 | + Cow::Borrowed("Must be a valid phone number") |
| 272 | + } |
| 273 | +
|
| 274 | + fn ip_invalid(&self, kind: IpKind) -> Cow<'static, str> { |
| 275 | + format!("Must be a valid {kind} address").into() |
| 276 | + } |
| 277 | +
|
| 278 | + fn matches_field_mismatch(&self, field: &dyn Display) -> Cow<'static, str> { |
| 279 | + format!("Must match the {field} field").into() |
| 280 | + } |
| 281 | +
|
| 282 | + fn email_invalid(&self, _reason: InvalidEmail) -> Cow<'static, str> { |
| 283 | + Cow::Borrowed("Must be a valid email address") |
| 284 | + } |
| 285 | +
|
| 286 | + fn ascii_invalid(&self) -> Cow<'static, str> { |
| 287 | + Cow::Borrowed("Must contain only ASCII characters") |
| 288 | + } |
| 289 | +
|
| 290 | + fn alphanumeric_invalid(&self) -> Cow<'static, str> { |
| 291 | + Cow::Borrowed("Must contain only letters and numbers") |
| 292 | + } |
| 293 | +
|
| 294 | + fn required_not_set(&self) -> Cow<'static, str> { |
| 295 | + Cow::Borrowed("This field is required") |
| 296 | + } |
| 297 | +} |
| 298 | +``` |
0 commit comments