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:
- 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
}
- 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
}
}
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"
}
}
}
}
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),
}
Where ApiResponseError is:
pub enum ApiResponseError {
Simple(String),
Complicated {
message: String,
error: Box<dyn ErasedSerialize>,
},
}
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>>,
}
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
}
example 2:
{
"message": "complicated error",
"error": {
"code": "1213213",
"foo": "bar"
}
}
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,
}
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,
}
3- Make an enum of the Errors that we expect that might happens:
#[derive(Debug)]
pub enum ApiError {
BadClientData(ValidationErrors),
UserAlreadyRegistered,
DbInternalError,
HashingError,
JWTEncodingError,
}
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),
}
}
}
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)
}
}
}
}
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))
}
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"
}
}
}
}
and success response with token:
{
"data": {
"token": "jwt_token"
},
"error": null
}
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)
Congratulations for your first post on DEV!
Thanks ! much appreciated !