DEV Community

ippsav
ippsav

Posted on

Rust Backend API With Axum

Formated API response

The API is an url shortner written in Rust, using the axum web framework.
While building the API I wanted to make the Response in a unified format across all over the routes where it has data and error object.(Like GraphQL)

Examples:

  1. In case of a response returned with valid data:
{
    "data": {
        "links": [
            {
                "id": "397bf38e-0a3a-469f-80fc-5f6a91040162",
                "name": "google link",
                "slug": "google_shortned_link",
                "redirect_to": "http://google.com",
                "owner_id": "cf907089-5d5b-48db-8282-3e8132d0cbbd",
                "created_at": "2022-12-22T23:58:24.045668",
                "updated_at": null
            },
            {
                "id": "57c391da-14a6-424a-ac24-99fd24410399",
                "name": "twitter link",
                "slug": "twitter_shortned_link",
                "redirect_to": "http://twitter.com",
                "owner_id": "cf907089-5d5b-48db-8282-3e8132d0cbbd",
                "created_at": "2022-12-22T12:57:49.084308",
                "updated_at": null
            }
        ]
    },
    "error": null
}
Enter fullscreen mode Exit fullscreen mode
  1. For the error side there are 2 types of errors, simple error which contains only a string that gives info about the error like this:
{
    "data": null,
    "error": {
        "message": "link with the name or slug provided already exists",
        "error": null
    }
}
Enter fullscreen mode Exit fullscreen mode

But when the error is complicated let's say the client data was invalid and we wanted to give precise info about what's wrong that's when the error object will contain an object that gives more info about what went wrong like:

{
    "data": null,
    "error": {
        "message": "invalid data from client",
        "error": {
            "fields": {
                "email": "invalid email",
                "password": "invalid length"
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

First Approach

So basically the API response will be either an object with valid data or an error which indicate what went wrong, or just a status code when needed.

Converting this into Rust code will be like the following:

pub enum ApiResponse<T: Serialize> {
    Data {
        data: T,
        status: StatusCode,
    },
    Error {
        error: ApiResponseError,
        status: StatusCode,
    },
    StatusCode(StatusCode),
}
Enter fullscreen mode Exit fullscreen mode

Where ApiResponseError is:

pub enum ApiResponseError {
    Simple(String),
    Complicated {
        message: String,
        error: Box<dyn ErasedSerialize>,
    },
}
Enter fullscreen mode Exit fullscreen mode

The unified API response will be mapped to a Serialized struct like the following:

#[derive(Serialize)]
pub struct ApiResponseErrorObject {
    pub message: String,
    pub error: Option<Box<dyn ErasedSerialize>>,
}
Enter fullscreen mode Exit fullscreen mode

which will result in having errors that can be written in a complicated and simple format like the following:
example 1:

    {
        "message": "simple error",
        "error": null
    }
Enter fullscreen mode Exit fullscreen mode

example 2:

    {
        "message": "complicated error",
        "error": {
            "code": "1213213",
            "foo": "bar"
        }
    }
Enter fullscreen mode Exit fullscreen mode

Now let's get this to work !

Usage

Here I'll be showing how we can use the define approach above to make a register handler.

1- We are expecting from the client to send some data to register the user and for that we will create the following struct with some validation on it using the validator crate !

#[derive(Debug, Validate, Deserialize)]
pub struct RegisterUserInput {
    #[validate(length(min = 6, max = 20))]
    pub username: String,
    #[validate(email)]
    pub email: String,
    #[validate(length(min = 5, max = 25))]
    pub password: String,
}
Enter fullscreen mode Exit fullscreen mode

2- Making a response struct that we will be sending to the client after a successful request:

#[derive(Serialize, Debug)]
pub struct RegisterResponseObject {
    token: String,
}
Enter fullscreen mode Exit fullscreen mode

3- Make an enum of the Errors that we expect that might happens:

#[derive(Debug)]
pub enum ApiError {
    BadClientData(ValidationErrors),
    UserAlreadyRegistered,
    DbInternalError,
    HashingError,
    JWTEncodingError,
}
Enter fullscreen mode Exit fullscreen mode

and since we have a complicated error that might happen which is the client providing bad data, in which we want to provide more information about what's wrong with the data provided like the following:


#[derive(Debug, Serialize)]
pub struct ResponseError {
    pub fields: Option<HashMap<String, String>>,
}

impl From<ValidationErrors> for ResponseError {
    fn from(v: ValidationErrors) -> Self {
        let mut hash_map: HashMap<String, String> = HashMap::new();
        v.field_errors().into_iter().for_each(|(k, v)| {
            let msg = format!("invalid {}", v[0].code);

            hash_map.insert(k.into(), msg);
        });
        Self {
            fields: Some(hash_map),
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

and then implement From trait on the ApiError enum to be able to make it into an ApiResponseData<ResponseError> like the following:

impl From<ApiError> for ApiResponseData<ResponseError> {
    fn from(value: ApiError) -> Self {
        match value {
            ApiError::BadClientData(err) => ApiResponseData::error(
                Some(ResponseError::from(err)),
                "invalid data from client",
                StatusCode::BAD_REQUEST,
            ),
            ApiError::UserAlreadyRegistered => {
                ApiResponseData::error(None, "user already registered", StatusCode::FORBIDDEN)
            }
            ApiError::DbInternalError | ApiError::HashingError | ApiError::JWTEncodingError => {
                ApiResponseData::status_code(StatusCode::INTERNAL_SERVER_ERROR)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

4- And finally create the register handler like the following:

pub async fn register_handler(
    State(db_connection): State<DatabaseConnection>,
    State(secrets): State<Secrets>,
    Json(create_user): Json<RegisterUserInput>,
) -> ApiResponse<RegisterResponseObject, ResponseError> {
  // this will return an error in case the fields validation went wrong.
  create_user.validate().map_err(ApiError::BadClientData)?;
  /*
   BLOCK OF CODE
  */
  let token = make_token();
  let data = RegisterResponseObject{ token };
  Ok(ApiResponseData::success_with_data(data, StatusCode::OK))
}
Enter fullscreen mode Exit fullscreen mode

which will result in having this response in case of a bad client data:

{
    "data": null,
    "error": {
        "message": "invalid data from client",
        "error": {
            "fields": {
                "email": "invalid email",
                "password": "invalid length"
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

and success response with token:

{
    "data": {
        "token": "jwt_token"
    },
    "error": null
}
Enter fullscreen mode Exit fullscreen mode

Github repo: https://github.com/ippsav/Dinoly

The branch with latest changes is the one named "migrating-to-api-response-v2".

Help, refactoring ideas, different solutions and questions are all much appreciated !

Top comments (2)

Collapse
 
szabgab profile image
Gabor Szabo

Congratulations for your first post on DEV!

Collapse
 
ippsav profile image
ippsav

Thanks ! much appreciated !