rust
74 lines · 8 steps
A validated JSON extractor in Axum
Wrap Axum's Json extractor so request bodies are both deserialized and validated before a handler ever runs.
Explained by
highlit
1use axum::{
2 async_trait,
3 extract::{rejection::JsonRejection, FromRequest, Request},
4 http::StatusCode,
5 response::{IntoResponse, Response},
6 Json,
7};
8use serde::de::DeserializeOwned;
9use serde_json::json;
10use std::collections::BTreeMap;
11use validator::{Validate, ValidationErrors};
12
13pub struct ValidatedJson<T>(pub T);
14
15pub enum ValidationRejection {
16 Json(JsonRejection),
17 Invalid(ValidationErrors),
18}
19
20#[async_trait]
21impl<T, S> FromRequest<S> for ValidatedJson<T>
22where
23 T: DeserializeOwned + Validate,
24 S: Send + Sync,
25{
26 type Rejection = ValidationRejection;
27
28 async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
29 let Json(value) = Json::<T>::from_request(req, state)
30 .await
31 .map_err(ValidationRejection::Json)?;
32 value.validate().map_err(ValidationRejection::Invalid)?;
33 Ok(ValidatedJson(value))
34 }
35}
36
37impl IntoResponse for ValidationRejection {
38 fn into_response(self) -> Response {
39 match self {
40 ValidationRejection::Json(rejection) => (
41 rejection.status(),
42 Json(json!({ "message": rejection.body_text() })),
43 )
44 .into_response(),
45 ValidationRejection::Invalid(errors) => {
46 let fields: BTreeMap<_, Vec<String>> = errors
47 .field_errors()
48 .into_iter()
49 .map(|(field, errs)| {
50 let messages = errs
51 .iter()
52 .map(|e| {
53 e.message
54 .as_ref()
55 .map(|m| m.to_string())
56 .unwrap_or_else(|| e.code.to_string())
57 })
58 .collect();
59 (field, messages)
60 })
61 .collect();
62
63 (
64 StatusCode::UNPROCESSABLE_ENTITY,
65 Json(json!({
66 "message": "Validation failed",
67 "errors": fields,
68 })),
69 )
70 .into_response()
71 }
72 }
73 }
74}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Custom extractors let you enforce invariants once, so handlers receive data that is already guaranteed valid.
- 2Splitting rejection into distinct variants keeps deserialization and validation errors separately shaped in the response.
- 3Implementing IntoResponse on your error type gives you full control over status codes and JSON error bodies.
Related explainers
rust
use serde::Deserialize; #[derive(Debug, Deserialize)] #[serde(untagged)]
Parsing flexible JSON shapes with serde
deserialization
enums
json
Intermediate
6 steps
ruby
require "shellwords" require "open3" module Backup
Building safe shell commands in Ruby
shell-out
subprocess
command-injection
Intermediate
7 steps
php
<?php namespace App\Services\Checkout;
Validating coupons with Laravel's Pipeline
pipeline
chain of responsibility
transactions
Intermediate
7 steps
rust
use std::cmp::{Ordering, Reverse}; use std::collections::BinaryHeap; use std::fs::File; use std::io::{self, BufRead, BufReader, BufWriter, Lines, Write};
K-way merge of sorted logs in Rust
binary-heap
k-way-merge
streaming-io
Intermediate
8 steps
php
<?php namespace App\Services;
How a password strength validator works in PHP
validation
regular-expressions
data-driven
Intermediate
8 steps
rust
use axum::{ extract::{Path, State}, response::sse::{Event, KeepAlive, Sse}, };
Streaming import progress with SSE in Axum
server-sent-events
streams
watch-channel
Advanced
7 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/a-validated-json-extractor-in-axum-explained-rust-dd20/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.