HTTP requests using Rust Reqwest

About Reqwest

At Qxf2, we have always been curious to constantly learn and discover new possibilities and insights. This led us to explore the Reqwest crate, a standout tool for Rust, further enriching our technical toolkit. Reqwest is a client solution for Rust for making asynchronous HTTP requests. It provides API’s for interacting with various HTTP operations. It’s ease of use and its key features and capabilities make it a popular choice for handling HTTP communication. By default it supports HTTPS and ensures secure communication, handles timeouts and automatic retries. In this article, we will showcase few basic capabilities of reqwest library.

Our Application

For this post, we’ll utilize our Cars API Web Application specifically designed for our internal API Automation training. The application has endpoints for you to practice automating GET, POST, PUT and DELETE methods. In the following sections, we’ll use the GET (get cars list) and POST (add new car) requests on the Cars API using Reqwest crate.

Installation

To use Reqwest, you must include Reqwest and Tokio libraries in your Cargo.toml file. Tokio serves as the asynchronous runtime used under the hood of reqwest. Cargo will install them when you build your program.

[dependencies]
reqwest = { version = "0.11", features = ["json"] }
tokio = { version = "1.12.0", features = ["full"] } 
serde = "1.0"
serde_derive = "1.0"
serde_json = "1.0"
toml = "0.5.10"

Making a GET request

Lets use a GET request to retrieve the cars data from the server. The provided code snippet demonstrates a simple Rust program that fetches information about cars from the server. The get_cars() function makes a GET request to the specified URL (https://cars-app.qxf2.com/cars) with basic authentication and retrieves the response body as json. The async/await ensures continuous execution.

async fn get_cars(username: &str, password: &str) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
    // Read the content of the "config.toml" file into a string
    let client = reqwest::Client::new();
    let response = match client.get("https://cars-app.qxf2.com/cars")
        .basic_auth(username, Some(password))      
        .timeout(Duration::from_secs(10))  
        .send()
        .await {
            Ok(res) => {                
                // Check for specific status codes
                if res.status().is_client_error() {
                    Err(format!("Server returned a client error: {}", res.status()).into())
                } else if res.status().is_server_error() {
                    return Err(format!("Server returned a error, check your request data: {}", res.status()).into());
                }               
                else {
                    Ok(res)
                }
            }
            Err(err) => {
                // Handle specific error types
                if err.is_connect() {
                    return Err("Failed to connect to the server. Please make sure the server is running.".into());
                }
                else if err.is_timeout() {
                    return Err(format!("Request timed out: {}", err).into());
                } else {
                    return Err(Box::new(err));
                }
            }
    };
 
    // Handle the response and extract JSON
    let result_json: serde_json::Value = match response {
        Ok(res) => match res.json().await {
            Ok(json) => json,
            Err(err) => return Err(Box::new(err)),
        },
        Err(err) => return Err(err),
    };
 
    Ok(result_json)
}

This function uses a configuration file “config.toml” to store the username and password details. It performs an HTTP GET request with basic authentication and extracts the username and password from the Config struct. Upon a successful request (returning Ok(res)), we check for specific HTTP status codes in the response. We have implemented error-handling mechanism to manage various error scenarios and ensure that the system responds appropriately to different types of issues. Specifically, our error-handling includes

  • Client Errors (4xx status codes) like invalid requests or unauthorized access attempts
  • Server Errors (5xx status codes) like unexpected failures in server-side processing
  • Timeout Errors for requests exceeding time limits
  • Connection Errors in cases of failed server connections
  • If reading the response text is successful (returns Ok(json)), store it in the body variable. If there is an error while reading the response (returns Err(err)), return the error wrapped in a Box.

    The result looks below

    Cars api get response json
    Cars api get response json

    Making a POST request

    Moving on to the POST request, add_cars() function is responsible for adding cars to a server through an HTTP POST request. Basic authentication is applied, and the request includes a JSON payload (provided as the json_data parameter). The content type header is set to “application/json”, to indicate that the payload is in JSON format. The function returns a Result containing either the response body (if successful) or an error (if any step encountered an error). The error is wrapped in a Box for handling various error types.

    #[tokio::main]
    async fn add_cars(username: &str, password: &str, json_data: &str) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
        let client = reqwest::Client::new();
     
        let response = match client.post("https://cars-app.qxf2.com/cars/add")
            .basic_auth(username, Some(password))
            .timeout(Duration::from_secs(10))
            .header(reqwest::header::CONTENT_TYPE, "application/json")
            .body(json_data.to_string())
            .send()
            .await {
                Ok(res) => {
                    // Check for specific status codes
                    if res.status().is_client_error() {
                        return Err(format!("Server returned a client error: {}", res.status()).into());
                    }
                    else if res.status().is_server_error() {
                        return Err(format!("Server returned a error, check your request data: {}", res.status()).into());
                    } else {
                    Ok(res)
                }
                }
                Err(err) => {
                    // Handle specific error types
                    if err.is_connect() {
                        return Err("Failed to connect to the server. Please make sure the server is running.".into());
                    } else if err.is_timeout() {
                        return Err(format!("Request timed out: {}", err).into());
                    }
                    else {
                        return Err(Box::new(err));
                    }
                }
        };
     
        // Handle the response and extract JSON
        let result_json: serde_json::Value = match response {
            Ok(res) => match res.json().await {
                Ok(json) => json,
                Err(err) => return Err(Box::new(err)),
            },
            Err(err) => return Err(err),
        };
     
        Ok(result_json)
    }

    The output looks like this.

    Cars api post response json
    Cars api post response json










    Here is the main.rs function illustrates the asynchronous execution of two functions: one for retrieving cars get_cars() and another for adding a new car add_cars(). The two helper functions
    validate_get_response Validates the JSON response from the GET request to ensure it conforms to the expected structure (CarsResponse), including checking for required fields in each car item.
    validate_add_response Validates the JSON response from the POST request for a “successful” field that indicates whether the addition was successful. You can find the complete code here

    main.rs

    //Rust script using reqwests to make Get and Post call to cars api to get and add cars
    use std::fs;
    use serde_derive::{Deserialize, Serialize};
    use std::time::Duration;
    use serde_json::Value as JsonValue;
     
    #[derive(Debug, Deserialize)]
    struct ApiConfig {
        username: String,
        password: String,
    }
    #[derive(Debug, Deserialize)]
    struct Config {
        api: ApiConfig,
    }
    #[derive(Debug, Deserialize, Serialize)]
    struct Car {
        brand: String,
        car_type: String,
        name: String,
        price_range: String,
    }
    #[derive(Debug, Deserialize, Serialize)]
    struct CarsResponse {
        cars_list: Vec<Car>,
        successful: bool,
    }
    #[tokio::main]
    async fn main() {
        let config_str = fs::read_to_string("config.toml").expect("Failed to read config file");
        // convert string into a Config struct
        let config: Config = toml::from_str(&config_str).expect("Failed to parse config");
        // Read the username and password
        let username = &config.api.username;
        let password = &config.api.password;
        match get_cars(username, password).await {
            Ok(json_response) => {
                if validate_get_response(&json_response) {
                    println!("Cars response is valid.");
                    println!("Cars: {:#?}", json_response);
                } else {
                    println!("Invalid cars response structure.");
                }
            }
            Err(err) => println!("Error: {}", err),
        }
     
        let json_data = r#"
            {
                "name": "Figo",
                "brand": "Ford",
                "price_range": "5-6lacs",
                "car_type": "hatchback"
            }
        "#;
     
        match add_cars(username, password, json_data).await {
            Ok(response) => {
                if validate_add_response(&response) {
                    println!("Car addition successful.");
                    println!("Response: {:#?}", response);
                } else {
                    println!("Invalid car addition response structure.");
                }
            }
            Err(err) => println!("Error: {}", err),
        }
    }
     
    fn validate_get_response(cars_response: &JsonValue) -> bool {
        // Validate that the JSON response has the expected structure
        if let Ok(parsed_response) = serde_json::from_value::<CarsResponse>(cars_response.clone()) {
            // Check if each Car object in cars_list has all required fields
            for car in &parsed_response.cars_list {
                if car.brand.is_empty() || car.car_type.is_empty() || car.name.is_empty() || car.price_range.is_empty() {
                    return false;
                }
            }
            true
        } else {
            false 
        }
    }
     
    fn validate_add_response(response: &serde_json::Value) -> bool {
        // Check if the response contains the successful response
        if let Some(successful) = response.get("successful") {
            if let Some(successful_bool) = successful.as_bool() {
                return successful_bool;
            }
        }
        false // If the expected field or type is missing, consider the response as invalid
    }
     
    async fn get_cars(username: &str, password: &str) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
        // Read the content of the "config.toml" file into a string
     
        let client = reqwest::Client::new();
        let response = match client.get("http://localhost:5000/cars")
            .basic_auth(username, Some(password))      
            .timeout(Duration::from_secs(10))  
            .send()
            .await {
                Ok(res) => {                
                    // Check for specific status codes
                    if res.status().is_client_error() {
                        Err(format!("Server returned a client error: {}", res.status()).into())
                    } else if res.status().is_server_error() {
                        return Err(format!("Server returned a error, check your request data: {}", res.status()).into());
                    }               
                    else {
                        Ok(res)
                    }
                }
                Err(err) => {
                    // Handle specific error types
                    if err.is_connect() {
                        return Err("Failed to connect to the server. Please make sure the server is running.".into());
                    }
                    else if err.is_timeout() {
                        return Err(format!("Request timed out: {}", err).into());
                    } else {
                        return Err(Box::new(err));
                    }
                }
        };
     
        // Handle the response and extract JSON
        let result_json: serde_json::Value = match response {
            Ok(res) => match res.json().await {
                Ok(json) => json,
                Err(err) => return Err(Box::new(err)),
            },
            Err(err) => return Err(err),
        };
     
        Ok(result_json)
    }
     
     
    async fn add_cars(username: &str, password: &str, json_data: &str) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
        let client = reqwest::Client::new();
     
        let response = match client.post("http://localhost:5000/cars/add")
            .basic_auth(username, Some(password))
            .timeout(Duration::from_secs(10))
            .header(reqwest::header::CONTENT_TYPE, "application/json")
            .body(json_data.to_string())
            .send()
            .await {
                Ok(res) => {
                    // Check for specific status codes
                    if res.status().is_client_error() {
                        return Err(format!("Server returned a client error: {}", res.status()).into());
                    }
                    else if res.status().is_server_error() {
                        return Err(format!("Server returned a error, check your request data: {}", res.status()).into());
                    } else {
                    Ok(res)
                }
                }
                Err(err) => {
                    // Handle specific error types
                    if err.is_connect() {
                        return Err("Failed to connect to the server. Please make sure the server is running.".into());
                    } else if err.is_timeout() {
                        return Err(format!("Request timed out: {}", err).into());
                    }
                    else {
                        return Err(Box::new(err));
                    }
                }
        };
     
        // Handle the response and extract JSON
        let result_json: serde_json::Value = match response {
            Ok(res) => match res.json().await {
                Ok(json) => json,
                Err(err) => return Err(Box::new(err)),
            },
            Err(err) => return Err(err),
        };
     
        Ok(result_json)
    }

    The Reqwest crate is a powerful client solution for Rust designed to facilitate asynchronous HTTP requests. It supports HTTPS by default, ensuring secure communication, and includes features such as handling timeouts and automatic retries. In this article, we explored the application of Reqwest in the context of our Cars API Web Application and demonstrated the implementation of GET and POST requests in Rust, showcasing the ability to retrieve and add car information to the server. The code snippets illustrate how to handle errors, set up basic authentication, and structure requests with JSON payloads.


    References

    1. Reqwest Crate
    2. Making HTTP requests in Rust with Reqwest


    Hire testers from Qxf2

    Hire technical testers from Qxf2. Our QA engineers go well beyond traditional test automation. We have a wealth of experience in testing critical parts of complex systems. We work well with small teams and early stage products. Get in touch with us to hire technical testers for your product.


    One thought on “%1$s”

    Leave a Reply

    Your email address will not be published. Required fields are marked *