8 releases (5 breaking)

0.8.0 Apr 1, 2022
0.7.0 Dec 3, 2021
0.6.3 Aug 27, 2021
0.5.0 Aug 24, 2021
0.3.0 Jul 16, 2021

#504 in HTTP client

MIT license

8KB
137 lines

AjaRS

A small Rust library to remove the duplicated code between the definition of a Server side REST endpoint and the one of a REST Client that calls it.

The problem

When we create a REST endpoint, we need to provide at least four different values:

  1. The path of the resource
  2. The HTTP Method
  3. The JSON type consumed
  4. The JSON type produced

Exactly the same four values have to be provided when creating a REST client for that endpoint.

For example, if we use actix-web, an endpoint could be created with:

#[cfg(all(feature = "actix_web", feature = "reqwest"))]
mod without_ajars {
    use ajars::actix_web::actix_web::{App, Result, web::{self, Json}};
    use serde::{Deserialize, Serialize};

    fn server() {
        App::new().service(

            web::resource("/ping")  // PATH definition here
            
            .route(web::post()      // HTTP Method definition here
            
            .to(ping)               // The signature of the `ping` fn determines the
                                    // JSON types produced and consumed. In this case
                                    // PingRequest and PingResponse
            )
        );

        async fn ping(_body: Json<PingRequest>) -> Result<Json<PingResponse>> {
            Ok(Json(PingResponse {}))
        }
    }

    // Let's now declare a client using [reqwest](https://github.com/seanmonstar/reqwest)
    pub async fn client() {
        
        use ajars::reqwest::reqwest::ClientBuilder;
        
        let client = ClientBuilder::new().build().unwrap();

        let url = "http://127.0.0.1:8080/ping";    // Duplicated '/ping' path definition
        
        client.post(url)                           // Duplicated HTTP Post method definition
        
        .json(&PingRequest {})                     // Duplicated request type. Not checked at compile time
        
        .send().await.unwrap()
        .json::<PingResponse>().await.unwrap();    // Duplicated response type. Not checked at compile time
    }

    #[derive(Serialize, Deserialize, Debug)]
    pub struct PingRequest {}

    #[derive(Serialize, Deserialize, Debug)]
    pub struct PingResponse {}
}

Wouldn't it be good to have those values declared only once with all types checked at compile time?

The AjaRs solution

Ajars allows a single definition for both the client and server. This removes code duplication and, at the same time, allows compile time verification that the request and response types are correct.

Let's now redefine the previous endpoint using Ajars: definition of the previous endpoint:

#[cfg(all(feature = "actix_web", feature = "reqwest"))]
mod with_ajars {
    use ajars::Rest;
    use serde::{Deserialize, Serialize};

    // This defines a 'POST' call with path /ping, request type 'PingRequest' and response type 'PingResponse'
    // This should ideally be declared in a commond library imported by both the server and the client
    pub const PING: Rest<PingRequest, PingResponse> = Rest::post("/ping");


    // The the server side endpoint creation now becomes:
    fn server() {

        use ajars::actix_web::ActixWebHandler;
        use ajars::{actix_web::actix_web::{App, HttpServer, ResponseError}};
        use derive_more::{Display, Error};

        HttpServer::new(move || 
            App::new().service(
                PING.to(ping) // here Ajarj takes care of the endpoint creation
            )
        );

        #[derive(Debug, Display, Error)]
        enum UserError {
            #[display(fmt = "Validation error on field: {}", field)]
            ValidationError { field: String },
        }
        impl ResponseError for UserError {}

        async fn ping(_body: PingRequest) -> Result<PingResponse, UserError> {
            Ok(PingResponse {})
        }

        // start the server...

    }
        
    // The client, using reqwest, becomes:
    async fn client() {
        
        use ajars::reqwest::{AjarsReqwest, reqwest::ClientBuilder};

        let ajars = AjarsReqwest::new(ClientBuilder::new().build().unwrap(), "http://127.0.0.1:8080");
        
        // Performs a POST request to http://127.0.0.1:8080/ping
        // The PingRequest and PingResponse types are enforced at compile time
        let response = ajars
            .request(&PING)
            .send(&PingRequest {})
            .await
            .unwrap();
    }

    #[derive(Serialize, Deserialize, Debug)]
    pub struct PingRequest {}

    #[derive(Serialize, Deserialize, Debug)]
    pub struct PingResponse {}
}

Supported clients

WASM (web-sys) in the browser

Ajars provides a lightweight client implementation based on web-sys, this is to be used in WASM based web frontends that run in a browser (e.g. Yew, Sycamore, etc...).

To use it enable the web feature, in the Cargo.toml file:

ajars = { version = "LAST_VERSION", features = ["web"] }

Example:

#[cfg(feature = "web")]
mod web { 
    use ajars::web::AjarsWeb;
    use ajars::Rest;
    use serde::{Deserialize, Serialize};

    pub const PING: Rest<PingRequest, PingResponse> = Rest::post("/ping");

    async fn client() {

        let ajars = AjarsWeb::new("").expect("Should build Ajars");
        
        let response = ajars
            .request(&PING)           // <-- Here's everything required
            .send(&PingRequest {})
            .await
            .unwrap();
    }

    #[derive(Serialize, Deserialize, Debug)]
    pub struct PingRequest {}

    #[derive(Serialize, Deserialize, Debug)]
    pub struct PingResponse {}
}

Reqwest

To use it with reqwest enable the reqwest feature, in the Cargo.toml file:

ajars = { version = "LAST_VERSION", features = ["reqwest"] }

Example:

#[cfg(feature = "reqwest")]
mod reqwest { 
    use ajars::Rest;
    use ajars::reqwest::{AjarsReqwest, reqwest::ClientBuilder};
    use serde::{Deserialize, Serialize};

    pub const PING: Rest<PingRequest, PingResponse> = Rest::post("/ping");

    async fn client() {

        let ajars = AjarsReqwest::new(ClientBuilder::new().build().unwrap(), "http://127.0.0.1:8080");
        
        let response = ajars
            .request(&PING)           // <-- Here's everything required
            .send(&PingRequest {})
            .await
            .unwrap();
    }

    #[derive(Serialize, Deserialize, Debug)]
    pub struct PingRequest {}

    #[derive(Serialize, Deserialize, Debug)]
    pub struct PingResponse {}
}

Surf

To use it with surf enable the surf feature, in the Cargo.toml file:

ajars = { version = "LAST_VERSION", features = ["surf"] }

Example:

#[cfg(feature = "surf")]
mod surf { 
    use ajars::Rest;
    use ajars::surf::AjarsSurf;
    use serde::{Deserialize, Serialize};

    pub const PING: Rest<PingRequest, PingResponse> = Rest::post("/ping");

    async fn client() {

        let ajars = AjarsSurf::new(ajars::surf::surf::client(), "http://127.0.0.1:8080");
        
        let response = ajars
            .request(&PING)            // <-- Here's everything required
            .send(&PingRequest { })
            .await
            .unwrap();
    }

    #[derive(Serialize, Deserialize, Debug)]
    pub struct PingRequest {}

    #[derive(Serialize, Deserialize, Debug)]
    pub struct PingResponse {}
}

Supported servers

Actix-web

To use with actix-web enable the actix_web feature, in the Cargo.toml file:

ajars = { version = "LAST_VERSION", features = ["actix_web"] }

Example:

#[cfg(feature = "actix_web")]
mod actix_web { 
    use ajars::Rest;
    use serde::{Deserialize, Serialize};
    use ajars::actix_web::ActixWebHandler;
    use ajars::actix_web::actix_web::{App, HttpServer, ResponseError};
    use derive_more::{Display, Error};

    pub const PING: Rest<PingRequest, PingResponse> = Rest::post("/ping");

    async fn server() {

        HttpServer::new(move || 
            App::new().service(
                PING.to(ping)    // <-- Here's everything required
            )
        )
        .bind("127.0.0.1:8080")
        .unwrap()
        .run()
        .await
        .unwrap();

    }

    #[derive(Debug, Display, Error)]
    enum UserError {
        #[display(fmt = "Validation error on field: {}", field)]
        ValidationError { field: String },
    }
    impl ResponseError for UserError {}

    async fn ping(_body: PingRequest) -> Result<PingResponse, UserError> {
        Ok(PingResponse {})
    }

    #[derive(Serialize, Deserialize, Debug)]
    pub struct PingRequest {}

    #[derive(Serialize, Deserialize, Debug)]
    pub struct PingResponse {}
}

Axum

To use with axum enable the axum feature, in the Cargo.toml file:

ajars = { version = "LAST_VERSION", features = ["axum"] }

Example:

#[cfg(feature = "axum")]
mod axum { 
    use ajars::Rest;
    use ajars::axum::axum::{body::{boxed, Body, BoxBody, HttpBody}, http::Response, response::IntoResponse, Router};
    use ajars::axum::AxumHandler;
    use serde::{Deserialize, Serialize};
    use std::net::SocketAddr;
    use derive_more::{Display, Error};

    pub const PING: Rest<PingRequest, PingResponse> = Rest::post("/ping");

    async fn server() {

        let app = Router::new()
                .merge(PING.to(ping));  // <-- Here's everything required

            let addr = SocketAddr::from(([127, 0, 0, 1], 8080));

            println!("Start axum to {}", addr);

            ajars::axum::axum::Server::bind(&addr).serve(app.into_make_service()).await.unwrap();

    }

    #[derive(Debug, Display, Error)]
    enum UserError {
        #[display(fmt = "Validation error on field: {}", field)]
        ValidationError { field: String },
    }

    impl IntoResponse for UserError {
        fn into_response(self) -> Response<BoxBody> {
            Response::new(boxed(Body::empty()))
        }
    }

    async fn ping(_body: PingRequest) -> Result<PingResponse, UserError> {
        Ok(PingResponse {})
    }

    #[derive(Serialize, Deserialize, Debug)]
    pub struct PingRequest {}

    #[derive(Serialize, Deserialize, Debug)]
    pub struct PingResponse {}
}

Dependencies

~7–10MB
~212K SLoC