Production-ready microservice in Rust: 10. Improve error handling

Posted 2023-11-18 09:40:00 by sanyi ‐ 5 min read

Improve error handling, return a JSON response on JSON parse errors

The last post was about the POST /v1/dogs endpoint. We implemented some error handling to return JSON responses when somethings goes wrong in the handlers::dogs::create handler, but our error handling is lacking. For example, when axum cannot parse a JSON request into a DogCreateRequest it returns a plain text error. I also noticed that our AppError struct is limited to StatusCode::INTERNAL_SERVER_ERROR responses. Also, the ErrorResponse struct's status field was a string, that is prone to errors too, we will use an enum instead.

Start with the status field: I will create a new Status enum with simple Success and Error cases in error.rs:

#[derive(Serialize)]
pub enum Status {
    #[serde(rename = "success")]
    Success,
    #[serde(rename = "error")]
    Error,
}

and replace the string field with it:

#[derive(Serialize)]
pub struct ErrorResponse {
    pub status: Status,
    pub message: String,
}

Similarly in DogCreateResponse:

#[derive(Serialize)]
pub struct DogCreateResponse {
    pub status: Status,
    pub data: Option<Model>,
}

and in LoginResponse:

#[derive(Serialize)]
pub struct LoginResponse {
    pub status: Status,
    pub token: String,
}

Now we can use the Status enum in all the places where we used the success or error string previously:

use crate::api::response::error::Status;

let response = DogCreateResponse {
    status: Status::Success,
    data: Some(dog),
};

Next we extend the AppError struct to add a status code (but default to StatusCode::INTERNAL_SERVER_ERROR when we have no such information:

pub struct AppError(pub StatusCode, pub anyhow::Error);

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        (
            self.0,
            Json(ErrorResponse {
                status: Status::Error,
                message: self.1.to_string(),
            }),
        )
            .into_response()
    }
}

impl<E> From<E> for AppError
where
    E: Into<anyhow::Error>,
{
    fn from(err: E) -> Self {
        Self(StatusCode::INTERNAL_SERVER_ERROR, err.into())
    }
}

Now we can use the AppError in our login handler too:

pub async fn login(
    State(state): State<Arc<ApplicationState>>,
    CustomJson(payload): CustomJson<LoginRequest>,
) -> Result<Json<LoginResponse>, AppError> {
    match entity::user::Entity::find()
        .filter(entity::user::Column::Username.eq(&payload.username))
        .all(state.db_conn.load().as_ref())
        .await
    {
        Ok(admins) => {
            if admins.is_empty() {
                return Err(AppError(
                    StatusCode::UNAUTHORIZED,
                    anyhow!("User is not an admin"),
                ));
            }

            let admin = &admins[0];
            if validate_password(&payload.password, &admin.password).is_err() {
                return Err(AppError(
                    StatusCode::UNAUTHORIZED,
                    anyhow!("Password mismatch"),
                ));
            }
        }
        Err(e) => return Err(AppError(StatusCode::UNAUTHORIZED, e.into())),
    }
    // ...
}

We had an unhandled unwrap after the token creation and that could cause application crashes, so fix this too:

let token = encode(
        &Header::default(),
        &claims,
        &EncodingKey::from_secret(secret.as_bytes()),
    )
    .unwrap_or("".to_string());

Finally, replace the original axum Json extractor with our custom implementation, so we can return nice JSON errors there too. Create a new middleware module under the api module:

pub mod json;

and add a json module under it:

use crate::api::response::error::Status;
use axum::http::Request;
use axum::{
    async_trait,
    extract::{rejection::JsonRejection, FromRequest},
    http::StatusCode,
};
use serde_json::{json, Value};

pub struct CustomJson<T>(pub T);

#[async_trait]
impl<S, B, T> FromRequest<S, B> for CustomJson<T>
where
    axum::Json<T>: FromRequest<S, B, Rejection = JsonRejection>,
    S: Send + Sync,
    B: Send + 'static,
{
    type Rejection = (StatusCode, axum::Json<Value>);

    async fn from_request(req: Request<B>, state: &S) -> Result<Self, Self::Rejection> {
        match axum::Json::<T>::from_request(req, state).await {
            Ok(value) => Ok(Self(value.0)),
            Err(rejection) => Err((
                rejection.status(),
                axum::Json(json!({
                    "status": Status::Error,
                    "message": rejection.body_text(),
                })),
            )),
        }
    }
}

Our new extractor is named CustomJson. The Rejection type can return a StatusCode and a JSON formatted data structure. The extractor is implemented in the from_request method: it will either return a JSON or a Rejection. We simply call the original axum JSON extractor, but catch its rejection case and convert it into our JSON formatted structure.

Now we simply replace the original extractor in both the dog::create and login::login handlers:

pub async fn create(
    Extension(_claims): Extension<TokenClaims>,
    State(state): State<Arc<ApplicationState>>,
    CustomJson(payload): CustomJson<DogCreateRequest>,
) -> Result<Json<DogCreateResponse>, AppError> {
    let dog_active_model = payload.into_active_model();
    let dog_model = dog_active_model.save(state.db_conn.load().as_ref()).await?;
    let dog = dog_model.try_into_model()?;

    let response = DogCreateResponse {
        status: Status::Success,
        data: Some(dog),
    };

    Ok(Json(response))
}
pub async fn login(
    State(state): State<Arc<ApplicationState>>,
    CustomJson(payload): CustomJson<LoginRequest>,
) -> Result<Json<LoginResponse>, AppError> {
...
}

When we test our endpoints with invalid data, now we will get nice JSON formatted errors:

$ curl -v -XPOST -d '{"usern": "admin", "password": "Pa$$wd123"}' -H 'Content-Type: application/json' http://127.0.0.1:8080/v1/login
*   Trying 127.0.0.1:8080...
* Connected to 127.0.0.1 (127.0.0.1) port 8080 (#0)
> POST /v1/login HTTP/1.1
> Host: 127.0.0.1:8080
> User-Agent: curl/7.81.0
> Accept: */*
> Content-Type: application/json
> Content-Length: 45
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 422 Unprocessable Entity
< content-type: application/json
< content-length: 133
< date: Sat, 18 Nov 2023 07:52:53 GMT
< 
* Connection #0 to host 127.0.0.1 left intact
{"message":"Failed to deserialize the JSON body into the target type: missing field `username` at line 1 column 45","status":"error"}

You can find the sample code on GitHub

Next article ยป

Tags:
rust microservice dog-shelter