This post documents what I learned about creating custom Axum extractor which can be used with the validator for validation.

Create a new test project:

➜ cargo new request-validator
cd request-validator

The first crate to be added is axum for Axum framework.

request-validator on ξ‚  main [?] via πŸ¦€ v1.79.0
➜ cargo add axum
    Updating crates.io index
      Adding axum v0.7.5 to dependencies
             Features:
             + form
             + http1
             + json
             + matched-path
             + original-uri
             + query
             + tokio
             + tower-log
             + tracing
             - __private_docs
             - http2
             - macros
             - multipart
             - ws
    Updating crates.io index
     Locking 82 packages to latest compatible versions
...

We also need tokio crate that works together with axum.

➜ cargo add tokio
    Updating crates.io index
      Adding tokio v1.38.0 to dependencies
             Features:
             - bytes
             - fs
             - full
             - io-std
             - io-util
             - libc
             - macros
             - mio
             - net
             - num_cpus
             - parking_lot
             - process
             - rt
             - rt-multi-thread
             - signal
             - signal-hook-registry
             - socket2
             - sync
             - test-util
             - time
             - tokio-macros
             - tracing
             - windows-sys

There are two features in tokio crate that we’re interested in: macros and rt-multi-thread, which are to be used behind the scene by Axum.

request-validator on ξ‚  main [?] via πŸ¦€ v1.79.0
➜ cargo add tokio -F macros -F rt-multi-thread
    Updating crates.io index
      Adding tokio v1.38.0 to dependencies
             Features:
             + macros
             + num_cpus
             + rt
             + rt-multi-thread
             + tokio-macros
             - bytes
             - fs
             - full
             - io-std
             - io-util
             - libc
             - mio
             - net
             - parking_lot
             - process
             - signal
             - signal-hook-registry
             - socket2
             - sync
             - test-util
             - time
             - tracing
             - windows-sys
     Locking 2 packages to latest compatible versions
      Adding hermit-abi v0.3.9 (latest: v0.4.0)
      Adding num_cpus v1.16.0

I also need serde crate for serializing and deserializing structs.

➜ cargo add serde --features derive
    Updating crates.io index
      Adding serde v1.0.204 to dependencies
             Features:
             + derive
             + serde_derive
             + std
             - alloc
             - rc
             - unstable

Next, I’m going to create a new struct called RequestUser for this example code.

#[derive(Deserialize)]
pub struct RequestUser {
    pub username: String,
    pub password: String,
}

I want to implement the custom extractor on the above struct. Here are some information about custom extractor:

An extractor is a type that implements FromRequest or FromRequestParts. Extractors are how you pick apart the incoming request to get the parts your handler needs.

In Axum document, click on Modules, then click on extractor. Check the Implemnting FromRequest section.

If your extractor needs to consume the request body you must implement FromRequest.

We are going to implement this ourselves manually. Further check the document: Click on FromRequest.

impl<T, S> FromRequest<S> for Json<T>
where
    T: DeserializeOwned,
    S: Send + Sync,

Then update my code. Since I don’t use generic type in my RequestUser struct, the generic T can be dropped.

#[async_trait]
impl<S> FromRequest<S> for RequestUser
where
    S: Send + Sync,
{
    type Rejection = (StatusCode, String);

    async fn from_request(req: Request, _state: &S) -> Result<Self, Self::Rejection> {
        let Json(user) = req.extract::<Json<RequestUser>, _>()
            .await
        Ok(user)
    }
}

But we still not haven’t yet implemented any validation. For this, we can use validator crate. The validator crate allows us to use macros to mark part of the struct for validating. This requires custom extractor (the whole point of why the custom extractor is implemented above.)

Let’s update the validator crate with derive feature to the application.

➜ cargo add validator --features derive
    Updating crates.io index
      Adding validator v0.18.1 to dependencies
             Features:
             + derive
             + validator_derive
             - card
             - card-validate
             - indexmap
             - unic
             - unic-ucd-common

We can now update the RequestUser struct to add validator as well as the messages when the eror occurs.

#[derive(Deserialize, Validate)]
pub struct RequestUser {
    #[validate(email(message = "must be a valid email"))]
    pub username: String,
    #[validate(length(min = 8, message = "must have at least 8 characters"))]
    pub password: String,
}

When we run the validate method once the user extracted out, we want to be shown the error messages if they exist.

#[async_trait]
impl<S> FromRequest<S> for RequestUser
where
    S: Send + Sync,
{
    type Rejection = (StatusCode, String);

    async fn from_request(req: Request, _state: &S) -> Result<Self, Self::Rejection> {
        let Json(user) = req.extract::<Json<RequestUser>, _>()
            .await
            .map_err(|error| (StatusCode::BAD_REQUEST, format!("{}", error)))?;

        Ok(user)
    }
}

Let’s test the result:

➜ curl -w '\n' -4 localhost:3000/user -H 'Content-Type: application/json' \
  -d '{"username": "kenno", "password": "1234"}'

password: must have at least 8 characters
username: must be a valid email
➜ curl -4 -w '\n' localhost:3000/user -H 'Content-Type: application/json' \
  -d '{"username": "kenno@example.com", "password": "1234"}' -v

* Host localhost:3000 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying 127.0.0.1:3000...
* Connected to localhost (127.0.0.1) port 3000
> POST /user HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/8.6.0
> Accept: */*
> Content-Type: application/json
> Content-Length: 53
>
< HTTP/1.1 400 Bad Request
< content-type: text/plain; charset=utf-8
< content-length: 41
< date: Fri, 12 Jul 2024 15:22:46 GMT
<
* Connection #0 to host localhost left intact
password: must have at least 8 characters
➜ curl -4 -w '\n' localhost:3000/user -H 'Content-Type: application/json' `
-d '{"username": "kenno@example.com", "password": "12345678"}' -v

* Host localhost:3000 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying 127.0.0.1:3000...
* Connected to localhost (127.0.0.1) port 3000
> POST /user HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/8.6.0
> Accept: */*
> Content-Type: application/json
> Content-Length: 57
>
< HTTP/1.1 200 OK
< content-type: text/html; charset=utf-8
< content-length: 34
< date: Fri, 12 Jul 2024 15:23:56 GMT
<
* Connection #0 to host localhost left intact
<h1>Hello, kenno@example.com!</h1>

Note using the custom extractor, the custom handler will not run at all if the validation fails.

The source code for this example project is available at: https://github.com/kenno/request-validator.

Special thank to Brooks Builds for creating the tutorial on how to create custom extractor for Axum. I strongly recommend that you check out his Introduction to Axum. series.

References: