Skip to content

Commit 635b4df

Browse files
committed
Form Validation
1 parent e027b90 commit 635b4df

32 files changed

Lines changed: 622 additions & 54 deletions

content/en/labs/a5.routes-and-error-responses.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ Run following `cargo add` commands from the `book_service` folder.
1515

1616
```shell
1717
cargo add serde
18-
cargo add serde_json
1918
cargo add jiff -F serde
2019
cargo add uuid -F serde
2120
```
Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
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+
```

content/en/labs/a6.generate-openapi-specification.md

Lines changed: 0 additions & 4 deletions
This file was deleted.

data/en/labs/sidebar.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,4 @@
99
- title: Configuration and Middleware
1010
- title: Database and DB Migrations
1111
- title: Routes and Error Responses
12-
- title: Generate OpenAPI Specification
12+
- title: Form Validation

docs/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<!doctype html><html lang=en-US><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><meta name=description content="Rust Programming Language Tutorials for Everyone!"><meta name=author content="Dumindu Madunuwan"><meta name=theme-color content="#ffffff" media="(prefers-color-scheme: light)"><meta name=theme-color content="#101010" media="(prefers-color-scheme: dark)"><title>Learning Rust · Rust Programming Language Tutorials for Everyone!</title><link rel=canonical href=https://learning-rust.github.io/><link rel=stylesheet href=/assets/css/home.min.2f64b534694f7783ee00c07c45f130ef260d59af33aefe9e41a6606a53c5de38.css integrity><link href=https://learning-rust.github.io/pagefind/pagefind-component-ui.css rel=stylesheet><link rel=manifest href=/manifest.json><link rel=icon href=/favicon/favicon.ico><link rel=icon href=/favicon/favicon-16x16.png sizes=16x16 type=image/png><link rel=icon href=/favicon/favicon-32x32.png sizes=32x32 type=image/png><link rel=apple-touch-icon href=/favicon/apple-touch-icon.png sizes=180x180><script async src="https://www.googletagmanager.com/gtag/js?id=G-FZHQCXSZ89"></script><script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag("js",new Date),gtag("config","G-FZHQCXSZ89")</script><meta property="og:title" content="Learning Rust"><meta property="og:description" content="Learning Rust - Rust Programming Language Tutorials for Everyone!"><meta property="og:type" content="article"><meta property="og:url" content="https://learning-rust.github.io/"><meta property="og:image" content="https://learning-rust.github.io/og.jpg"><meta property="og:site_name" content="Learning Rust"><meta name=twitter:card content="summary_large_image"><meta name=twitter:title content="Learning Rust"><meta name=twitter:description content="Learning Rust - Rust Programming Language Tutorials for Everyone!"><meta name=twitter:image content="https://learning-rust.github.io/og.jpg"><script type=application/ld+json>{"@context":"https://schema.org","@type":"Article","headline":"Learning Rust","description":"Learning Rust - Rust Programming Language Tutorials for Everyone!","image":"https:\/\/learning-rust.github.io\/og.jpg","author":{"@type":"Person","name":"Dumindu Madunuwan","url":"https:\/\/github.com\/dumindu"},"publisher":{"@type":"Organization","name":"Learning Rust","logo":{"@type":"ImageObject","url":"https:\/\/learning-rust.github.io\/"}},"dateModified":"2026-08-16T12:40:51Z","mainEntityOfPage":{"@type":"WebPage","@id":"https:\/\/learning-rust.github.io\/"}}</script></head><body><div id=content-wrapper><header id=site-header><div id=site-header-logo><a href=https://learning-rust.github.io/><img height=52px src=https://learning-rust.github.io/logo.svg alt="Site Logo"></a></div><div id=site-header-actions><div id=search-box><pagefind-modal-trigger compact=true></pagefind-modal-trigger><pagefind-modal></pagefind-modal></div><div id=theme-dropdown class=dropdown><button class=dropdown-btn aria-haspopup=menu aria-label="Select the theme">
1+
<!doctype html><html lang=en-US><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><meta name=description content="Rust Programming Language Tutorials for Everyone!"><meta name=author content="Dumindu Madunuwan"><meta name=theme-color content="#ffffff" media="(prefers-color-scheme: light)"><meta name=theme-color content="#101010" media="(prefers-color-scheme: dark)"><title>Learning Rust · Rust Programming Language Tutorials for Everyone!</title><link rel=canonical href=https://learning-rust.github.io/><link rel=stylesheet href=/assets/css/home.min.2f64b534694f7783ee00c07c45f130ef260d59af33aefe9e41a6606a53c5de38.css integrity><link href=https://learning-rust.github.io/pagefind/pagefind-component-ui.css rel=stylesheet><link rel=manifest href=/manifest.json><link rel=icon href=/favicon/favicon.ico><link rel=icon href=/favicon/favicon-16x16.png sizes=16x16 type=image/png><link rel=icon href=/favicon/favicon-32x32.png sizes=32x32 type=image/png><link rel=apple-touch-icon href=/favicon/apple-touch-icon.png sizes=180x180><script async src="https://www.googletagmanager.com/gtag/js?id=G-FZHQCXSZ89"></script><script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag("js",new Date),gtag("config","G-FZHQCXSZ89")</script><meta property="og:title" content="Learning Rust"><meta property="og:description" content="Learning Rust - Rust Programming Language Tutorials for Everyone!"><meta property="og:type" content="article"><meta property="og:url" content="https://learning-rust.github.io/"><meta property="og:image" content="https://learning-rust.github.io/og.jpg"><meta property="og:site_name" content="Learning Rust"><meta name=twitter:card content="summary_large_image"><meta name=twitter:title content="Learning Rust"><meta name=twitter:description content="Learning Rust - Rust Programming Language Tutorials for Everyone!"><meta name=twitter:image content="https://learning-rust.github.io/og.jpg"><script type=application/ld+json>{"@context":"https://schema.org","@type":"Article","headline":"Learning Rust","description":"Learning Rust - Rust Programming Language Tutorials for Everyone!","image":"https:\/\/learning-rust.github.io\/og.jpg","author":{"@type":"Person","name":"Dumindu Madunuwan","url":"https:\/\/github.com\/dumindu"},"publisher":{"@type":"Organization","name":"Learning Rust","logo":{"@type":"ImageObject","url":"https:\/\/learning-rust.github.io\/"}},"dateModified":"2026-08-18T02:31:46Z","mainEntityOfPage":{"@type":"WebPage","@id":"https:\/\/learning-rust.github.io\/"}}</script></head><body><div id=content-wrapper><header id=site-header><div id=site-header-logo><a href=https://learning-rust.github.io/><img height=52px src=https://learning-rust.github.io/logo.svg alt="Site Logo"></a></div><div id=site-header-actions><div id=search-box><pagefind-modal-trigger compact=true></pagefind-modal-trigger><pagefind-modal></pagefind-modal></div><div id=theme-dropdown class=dropdown><button class=dropdown-btn aria-haspopup=menu aria-label="Select the theme">
22
<span><svg class="icon" height="20" viewBox="0 -960 960 960" width="20"><path d="M480-96q-79 0-149-30t-122.5-82.5T126-331 96-480q0-80 30.5-149.5t84-122 125-82.5T488-864q78 0 146.5 27T754-763t80.5 110T864-518q0 96-67 163t-163 67h-68q-8 0-14 5t-6 13q0 15 15 25t15 53q0 37-27 66.5T480-96zm0-384zm-173.5 18.5Q324-479 324-504t-17.5-42.5T264-564t-42.5 17.5T204-504t17.5 42.5T264-444t42.5-17.5zm120-144Q444-623 444-648t-17.5-42.5T384-708t-42.5 17.5T324-648t17.5 42.5T384-588t42.5-17.5zm192 0Q636-623 636-648t-17.5-42.5T576-708t-42.5 17.5T516-648t17.5 42.5T576-588t42.5-17.5zm120 144Q756-479 756-504t-17.5-42.5T696-564t-42.5 17.5T636-504t17.5 42.5T696-444t42.5-17.5zM480-168q11 0 17.5-8.5T504-192q0-16-15-28t-15-50 26.5-64 64.5-26h69q66 0 112-46t46-112q0-115-88.5-194.5T488-792q-134 0-227 91t-93 221 91 221 221 91z"/></svg>
33
</span><span><svg class="icon" height="20" viewBox="0 -960 960 960" width="20"><path d="m480-246 85.91-85.91Q577-343 591.5-343t25.5 11 11 25.5-10.93 25.43L505-169q-5.4 5-11.7 7.5t-13.5 2.5-13.5-2.5T455-169L342.93-281.07Q332-292 332.5-306.5T344-332t25.5-11 25.47 11.09L480-246zm0-468-85.91 85.91Q383-617 368.5-617T343-628t-11-25.5 10.93-25.43L455-791q5.4-5 11.7-7.5t13.5-2.5 13.5 2.5T505-791l112.07 112.07Q628-668 628-654t-11 25-25.5 11-25.59-10.97L480-714z"/></svg></span></button><ul role=menu class=dropdown-menu><li role=menuitem><button title=Light class=color-scheme data-value=light style=background:#fff;color:#000>
44
<span style=color:#000>Aa</span></button></li><li role=menuitem><button title=dark class=color-scheme data-value=dark style=background:#101010;color:#fff>

0 commit comments

Comments
 (0)