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