Software developer

Developer of webapps and more since 2016

11 Dec 2021

2765
A Rust Api pattern (Actix)

Tags

Hi there,

I'm writing this article to share my take on a Rust Api backend as a newbie in the ecosystem. It can be improved. But let me explain the pattern I setup for the API of this blog.

At first I need to say that I will focus on the REST Api part of the project and its architecture. The goal I wanted to reach was to "industrialise" the REST endpoints of entites in Rust.

IF you see a typo or an unclear part please reach me.

The database is composed of 4 tables :

public | project_categories | table

public | project_image_categories | table

public | project_images | table

public | projects | table

Some of those are updatable from the command line and from api endpoints and the api exposes methods to fetch their data.

Let's define structs for the project entity :

// src/models/project.rs

#[derive(Default, Deserialize)]
struct FindQuery {}
#[derive(Deserialize)]
struct ListQuery {}
#[derive(Deserialize)]
struct DeleteQuery {}
type ListResult = Vec<Project>;
type DeleteResult = Project;
#[derive(Deserialize)]
struct SaveQuery {}
#[derive(Deserialize)]
struct UpdateQuery {}
type Id = i64;

#[derive(Clone, Debug, Serialize, Deserialize, HttpFindListDelete)]
#[http_find_list_delete(Id, FindQuery, ListQuery, DeleteQuery, AppState)]
pub struct Project {
   pub id: Id,
   pub category_id: Id,
   pub title: String,
   pub slug: String,
   pub content: String,
   pub views_count: Id,
   pub likes_count: Id,
   pub category: Option<ProjectCategory>,
   pub images: Option<Vec<ProjectImage>>,
   pub primary_image: Option<ProjectImage>,
}

#[derive(Clone, Serialize, Deserialize, HttpCreate)]
#[http_create(SaveQuery, AppState)]
pub struct NewProject {
   pub category_id: Id,
   pub title: String,
   pub slug: Option<String>,
   pub content: String,
}

#[derive(Clone, Serialize, Deserialize, HttpUpdate)]
#[http_update(UpdateQuery, AppState)]
pub struct UpdatableProject {
   pub id: Id,
   pub title: String,
   pub content: String,
   pub category_id: Id,
}

The HttpFindListDelete, HttpCreate, HttpUpdate derive macros provide methods to the structs Project, NewProject, UpdatableProject. They are defined in a macro.

By declaring the structs NewProject (do note that this one does not have an id field) and UpdatableProject, you may implement methods on these and use those structs as the payload for the routes of the web server.

Now let's define some traits to implement respectively on the structs in the rest_macro crate :

// rest_macro/src/lib.rs
#[async_trait]
pub trait Model<ID, FQ, LQ, LR, DQ, DR, AppState> {
    async fn find(id: ID, query: &FQ, state: &AppState) -> Result<Box<Self>>;
    async fn list(query: &LQ, state: &AppState) -> Result<LR>;
    async fn delete(self: Self, query: &DQ, state: &AppState) -> Result<DR>;
}

#[async_trait]
pub trait NewModel<T, Q, AppState> {
    async fn save(self: Self, query: &Q, state: &AppState) -> Result<T>;
}

#[async_trait]
pub trait UpdatableModel<T, Q, AppState> {
    async fn update(self: Self, query: &Q, state: &AppState) -> Result<T>;
}

#[async_trait]
pub trait HttpCreate<Q, AppState> {
    async fn http_create(payload: web::Json<Box<Self>>, query: web::Query<Q>, app_state: web::Data<AppState>) -> Result<HttpResponse, HttpResponse>;
}

#[async_trait]
pub trait HttpFindListDelete<P, FQ, LQ, DQ, AppState> {
    async fn http_find(info: web::Path<P>, query: web::Query<FQ>, app_state: web::Data<AppState>) -> Result<HttpResponse, HttpResponse>;
    async fn http_list(query: web::Query<LQ>, app_state: web::Data<AppState>) -> Result<HttpResponse, HttpResponse>;
    async fn http_delete(info: web::Path<P>, query: web::Query<DQ>, app_state: web::Data<AppState>) -> Result<HttpResponse, HttpResponse>;
}

#[async_trait]
pub trait HttpUpdate<P, Q, AppState> {
    async fn http_update(info: web::Path<P>, payload: web::Json<Box<Self>>, query: web::Query<Q>, app_state: web::Data<AppState>) -> Result<HttpResponse, HttpResponse>;
}

These traits methods will be prompted to implement on each structs that implement these traits.

Let's define the proc_macro to automatically generate http_list, http_find, http_delete, http_update and http_create endpoints for the api using derive macros :

// rest_macro_derive/src/lib.rs

extern crate proc_macro;
use darling::FromMeta;
use quote::{quote, ToTokens};
use syn::{ self, Result as SynResult, AttributeArgs, Token, parse_macro_input };

struct HttpCreateDeriveParams (syn::Ident, syn::Ident);
impl syn::parse::Parse for HttpCreateDeriveParams {
    fn parse(input: syn::parse::ParseStream) -> SynResult<Self> {
        let content;
        syn::parenthesized!(content in input);
        let query = content.parse()?;
        content.parse::<Token![,]>()?;
        let app_state = content.parse()?;
        Ok(HttpCreateDeriveParams(query, app_state))
    }
}
#[proc_macro_derive(HttpCreate, attributes(http_create))]
pub fn http_create(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
    let ast = syn::parse(input).unwrap();
    impl_http_create_macro(&ast)
}

fn impl_http_create_macro(ast: &syn::DeriveInput) -> proc_macro::TokenStream {
    let attribute = ast.attrs.iter().filter(
        |a| a.path.segments.len() == 1 && a.path.segments[0].ident == "http_create"
    ).nth(0).expect("http_create attribute required for deriving HttpCreate!");

    let parameter: HttpCreateDeriveParams = syn::parse2(attribute.tokens.clone()).expect("Invalid http_create attribute!");
    let HttpCreateDeriveParams(query, app_state) = parameter;

    let name = &ast.ident;
    let gen = quote! {
        #[async_trait]
        impl HttpCreate<#query, #app_state> for #name {
            async fn http_create(payload: actix_web::web::Json<Box<#name>>, query: actix_web::web::Query<#query>, state: actix_web::web::Data<#app_state>) -> Result<actix_web::HttpResponse, actix_web::HttpResponse>{
                let params = query.into_inner();
                let to_save = payload.into_inner();
                let result = to_save.save(&params, &state).await;
                match result {
                    Ok(res) => Ok(actix_web::HttpResponse::Ok().body(serde_json::json!(res))),
                    Err(err) => Err(actix_web::HttpResponse::InternalServerError().body(err.to_string()))
                }
            }
        }
    };
    gen.into()
}

struct HttpFindListDeleteDeriveParams (syn::Ident, syn::Ident, syn::Ident, syn::Ident, syn::Ident);
impl syn::parse::Parse for HttpFindListDeleteDeriveParams {
    fn parse(input: syn::parse::ParseStream) -> SynResult<Self> {
        let content;
        syn::parenthesized!(content in input);
        let id = content.parse()?;
        content.parse::<Token![,]>()?;
        let find_query = content.parse()?;
        content.parse::<Token![,]>()?;
        let list_query = content.parse()?;
        content.parse::<Token![,]>()?;
        let delete_query = content.parse()?;
        content.parse::<Token![,]>()?;
        let app_state = content.parse()?;
        Ok(HttpFindListDeleteDeriveParams(id, find_query, list_query, delete_query, app_state))
    }
}


#[derive(Debug, FromMeta)]
struct RestfulInfo {
    pub scope: String,
    pub path: String,
}

impl ToTokens for RestfulInfo {
    fn to_tokens(&self, _tokens: &mut proc_macro2::TokenStream) {
        ()
    }
}

#[proc_macro_attribute]
pub fn actix_restful_info(args: proc_macro::TokenStream, input: proc_macro::TokenStream) -> proc_macro::TokenStream {
    let attrs_args = parse_macro_input!(args as AttributeArgs);
    let ast: syn::DeriveInput = syn::parse(input.clone()).unwrap();

    let args_tokens = match RestfulInfo::from_list(&attrs_args) {
        Ok(v) => v,
        Err(e) => { return proc_macro::TokenStream::from(e.write_errors()); }
    };
    let name  = ast.ident;
    let path =args_tokens.path;
    let scope = args_tokens.scope;
    let gen = quote! {
        impl RestfulPathInfo for #name {
            fn path() -> String  {
                let p = #path;
                let p = p.to_string();
                p
            }
            fn scope() -> &'static str {
                let p = #scope;
                p
            }
        }
    };
    let mut out:proc_macro::TokenStream = gen.into();
    out.extend::<proc_macro::TokenStream>(input);
    out
}

#[proc_macro_derive(HttpFindListDelete, attributes(http_find_list_delete))]
pub fn http_find_list_delete(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
    let ast = syn::parse(input).unwrap();
    impl_http_find_list_delete_macro(&ast)
}

fn impl_http_find_list_delete_macro(ast: &syn::DeriveInput) -> proc_macro::TokenStream {
    let attribute = ast.attrs.iter().filter(
        |a| a.path.segments.len() == 1 && a.path.segments[0].ident == "http_find_list_delete"
    ).nth(0).expect("http_find_list_delete attribute required for deriving HttpFindListDelete!");

    let parameter: HttpFindListDeleteDeriveParams = syn::parse2(attribute.tokens.clone()).expect("Invalid http_find_list_delete attribute!");
    let HttpFindListDeleteDeriveParams(id, find_query, list_query, delete_query, app_state) = parameter;

    let name = &ast.ident;
    let gen = quote! {
        #[derive(Deserialize)]
        struct ActixRestfulPath {
            id: #id
        }
        #[async_trait]
        impl HttpFindListDelete<ActixRestfulPath, #find_query, #list_query, #delete_query, #app_state> for #name {
            async fn http_list(
                query: actix_web::web::Query<#list_query>,
                state: actix_web::web::Data<#app_state>
            ) -> Result<actix_web::HttpResponse, actix_web::HttpResponse>{
                let params = query.into_inner();
                let result = #name::list(&params, &state).await;
                match result {
                    Ok(res) => Ok(actix_web::HttpResponse::Ok().body(serde_json::json!(res))),
                    Err(err) => Err(actix_web::HttpResponse::InternalServerError().body(err.to_string()))
                }
            }
            async fn http_find(
                info: actix_web::web::Path<ActixRestfulPath>,
                query: actix_web::web::Query<#find_query>,
                state: actix_web::web::Data<#app_state>
            ) -> Result<actix_web::HttpResponse, actix_web::HttpResponse> {
                let params = query.into_inner();
                let result = #name::find(info.id.into(), &params, &state).await;
                match result {
                    Ok(res) => Ok(actix_web::HttpResponse::Ok().body(serde_json::json!(res))),
                    Err(err) => Err(actix_web::HttpResponse::NotFound().body("ENTITY_NOT_FOUND"))
                }
            }
            async fn http_delete(
                info: actix_web::web::Path<ActixRestfulPath>,
                query: actix_web::web::Query<#delete_query>,
                state: actix_web::web::Data<#app_state>
            ) -> Result<actix_web::HttpResponse, actix_web::HttpResponse> {
                let params = query.into_inner();
                let find_params: #find_query = Default::default();
                let result = #name::find(info.id.into(), &find_params, &state).await;

                match result {
                    Ok(entity) => {
                        match entity.delete(&params, &state).await {
                            Ok(e) => Ok(actix_web::HttpResponse::Ok().body(serde_json::json!(e))),
                            Err(err) => Err(actix_web::HttpResponse::InternalServerError().body(err.to_string()))
                        }
                    }
                    Err(err) => Err(actix_web::HttpResponse::NotFound().body("ENTITY_NOT_FOUND"))
                }
            }
        }
    };
    gen.into()
}

struct HttpUpdateDeriveParams (syn::Ident, syn::Ident, syn::Ident, syn::Ident, syn::Ident);
impl syn::parse::Parse for HttpUpdateDeriveParams {
    fn parse(input: syn::parse::ParseStream) -> SynResult<Self> {
        let content;
        syn::parenthesized!(content in input);
        let id = content.parse()?;
        content.parse::<syn::Token![,]>()?;
        let query = content.parse()?;
        content.parse::<syn::Token![,]>()?;
        let output = content.parse()?;
        content.parse::<syn::Token![,]>()?;
        let find_query = content.parse()?;
        content.parse::<Token![,]>()?;
        let app_state = content.parse()?;
        Ok(HttpUpdateDeriveParams(id, query, output, find_query, app_state))
    }
}

#[proc_macro_derive(HttpUpdate, attributes(http_update))]
pub fn http_update(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
    let ast = syn::parse(input).unwrap();
    impl_http_update_macro(&ast)
}

fn impl_http_update_macro(ast: &syn::DeriveInput) -> proc_macro::TokenStream {
    let attribute = ast.attrs.iter().filter(
        |a| a.path.segments.len() == 1 && a.path.segments[0].ident == "http_update"
    ).nth(0).expect("http_update attribute required for deriving HttpUpdate!");

    let parameter: HttpUpdateDeriveParams = syn::parse2(attribute.tokens.clone()).expect("Invalid http_update attribute!");
    let HttpUpdateDeriveParams(id, query, output, find_query, app_state) = parameter;

    let name = &ast.ident;
    let gen = quote! {
        #[derive(Deserialize)]
        struct ActixRestfulUpdatePath {
            id: #id
        }
        #[async_trait]
        impl HttpUpdate<ActixRestfulUpdatePath, #query, #app_state> for #name {
            async fn http_update(
                info: actix_web::web::Path<ActixRestfulUpdatePath>,
                payload: actix_web::web::Json<Box<#name>>,
                query: actix_web::web::Query<#query>,
                state: actix_web::web::Data<#app_state>
            ) -> Result<actix_web::HttpResponse, actix_web::HttpResponse> {
                let to_update = payload.into_inner();
                let params = query.into_inner();
                let find_params: #find_query = Default::default();
                let result = #output::find(info.id.into(), &find_params, &state).await;

                match result {
                    Ok(entity) => {
                        match to_update.update(&params, &state).await {
                            Ok(e) => Ok(actix_web::HttpResponse::Ok().body(serde_json::json!(e))),
                            Err(err) => Err(actix_web::HttpResponse::InternalServerError().body(err.to_string()))
                        }
                    }
                    Err(err) => Err(actix_web::HttpResponse::NotFound().body("ENTITY_NOT_FOUND"))
                }
            }
        }
    };
    gen.into()
}

This code generates the methods http_list, http_find, http_delete, http_update and http_create for the structs that implements the macro HttpListFindDelete, HttpCreate and HttpUpdate as seen earlier.

For more informations on how to create derive macros in rust read the doc

These methods need to be implemented on the structs as required by the traits Model, NewModel and UpdatableModel :


// src/models/project.rs
#[async_trait]
impl Model<Id, FindQuery, ListQuery, ListResult, DeleteQuery, DeleteResult, AppState> for Project {
    async fn find(id: Id, _query: &FindQuery, _state: &AppState) -> Result<Box<Project>> {
        // fetch from somwhere with id and return result
    }
    async fn list(_query: &ListQuery, _state: &AppState) -> Result<ListResult> {
        // list
    }
    async fn delete(mut self: Self, _query: &DeleteQuery, _state: &AppState) -> Result<DeleteResult> {
        // hard or soft delete
    }
}

#[async_trait]
impl NewModel<Project, SaveQuery, AppState> for NewProject {
    async fn save(self: Self, _query: &SaveQuery, _state: &AppState) -> Result<Project> {
        // persist, and return Project entity
    }
}

#[async_trait]
impl UpdatableModel<UpdatableProject, UpdateQuery, AppState> for UpdatableProject {
    async fn update(mut self: Self, _query: &UpdateQuery, _state: &AppState) -> Result<UpdatableProject> {
       // update in db
    }
}

Now that the http_find, http_delete and http_all methods are implemented on the struct Project via the derive macro, let's call them from the endpoints of the webserver :

    let server = HttpServer::new(move || {
        App::new()
            .route("/projects", web::get().to(Project::http_all))
            .route("/projects", web::post().to(NewProject::http_create))
            .route("/projects/{id}", web::put().to(UpdatableProject::http_update)),
            .route("/projects/{id}", web::get().to(Project::http_find))
            .route("/projects/{id}", web::delete().to(Project::http_delete))
    })

Conclusion :

This article illustrates how you can implement a type safe Rest api using rust, actix and derive macros.

As being said at the begining, I did that as a beginner in Rust.

Edit: As some of you seemed interested, I've started working on a library of this: https://github.com/ctaque/actix-restful