Rust | How To Build A Rust API Using Hyper? (Step-by-step)

APIs are a key component of modern applications no matter the programming language they are written. Learning how to build a Rust API can be seen as a challenging task, especially on a programming language that is considered hard and with a steep learning curve. Hopefully, you will learn how to build a Rust API using Hyper in this article.

Here are the steps to build a Rust API with Hyper:

  1. Create a new project using cargo new
  2. Configure the dependencies using hyper and tokio
  3. Set up the server
  4. Configure the routes or API endpoints
  5. Define services on each route
  6. Test the API

What is Hyper?

Hyper is a low-level HTTP library defined as a fast and correct HTTP implementation for Rust. Hyper is used with Tokio, a platform for writing asynchronous applications without compromising speed.

Other higher-level HTTP libraries are built on top of Hyper such as Reqwest and Warp, an HTTP client and HTTP server framework respectively. This means Hyper can act as a client to communicate with web services and as a server to build web services.

Steps to build a Rust API with Hyper

In this tutorial, you are going to learn how to use Hyper as a server to build an API.

Create a new project using cargo new

First, create a new Rust project using the cargo new command followed by the name of the project. This tutorial will name the project rest-api-hyper. Therefore, the command should look like this.

cargo new rest-api-hyper

This will generate a basic project configuration containing the following files:

  • Cargo.toml: In this file you can configure the generate information about the project and dependencies needed.
  • Cargo.lock: This file contains the version of packages or dependencies defined in the [dependencies] section of the Cargo.toml file.
  • src/main.rs: This file contains by default the main() function. The main() function is the first function triggered when running a Rust project using cargo run.
  • target: This folder contains the binaries generated after compiling the Rust program.
  • .gitignore: This file contains the list of files that are not tracked in version control. By default it ignores the target folder.

Configure the dependencies using hyper and tokio

Open the Cargo.toml file and add hyper and tokio dependencies. You will also need serde and serde_json later to serialize response values, and rand to generate a random id when configuring the service triggered the POST API route.

[package]
name = "rest-api-hyper"
version = "0.1.0"
edition = "2021"

[dependencies]
hyper = { version = "0.14", features = ["full"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
rand = "0.8.4"

Set up the server

Open the main.rs file. This file contains by defect a main() function. You will configure this function to set up a web server.

Import necessary crates

First, import the following crates in the main.rs file.


use rand::Rng;
use std::net::SocketAddr;
use std::error::Error;
use hyper::body::Buf;
use hyper::server::conn::Http;
use hyper::service::service_fn;
use hyper::{header, Body, Method, Request, Response, StatusCode};
use serde::{Deserialize, Serialize};
use tokio::net::TcpListener;

Then, add the #[tokio::main] attribute to the main() function. This will allow you to use the async keyword in the main() function. Your code should look like the following code snippet.

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
   
}

The tokio::main macro creates a tokio::runtime::Runtime, or necessary tools including an I/O driver, a task scheduler, a timer, and a blocking bool to run asynchronous operations.

Note: Failing to add the #[tokio::main] attribute will cause the following error: Error[E0752]: main function is not allowed to be async . As the error says, the main function does not allow the async keyword by default.

Configure server to run on localhost:3000

Next, configure the server to run on a specific port. This tutorial configures the server in 127.0.0.1:3000 or localhost:3000 shown in the next code snippet.

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));

    let listener = TcpListener::bind(addr).await?;
    println!("Listening on http://{}", addr);   
}

The SocketAddr::from function generates a SocketAddr , or socket address. The TcpListener::bind function creates a TcpListener bound to a specific address, in this case, 127.0.0.1:3000.

Spawn an asynchronous task to use a service handling incoming connections

For this step, you accept incoming connections and serve the connections to a service handler. The service handler is a function in charge of redirecting the client to the a specific event handler based on the API route the client intends to make the request to.

For now, create a the service handler function called cars_handler.

async fn cars_handler(req: Request<Body>) -> Result<Response<Body>, Box<dyn Error + Send + Sync>> {
}

Now, go back to the main() function and spawn an asynchronous task that serves incoming connections to the service handler cars_handler.

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));

    let listener = TcpListener::bind(addr).await?;
    println!("Listening on http://{}", addr);
    loop {
        let (stream, _) = listener.accept().await?;

        tokio::task::spawn(async move {
            if let Err(err) = Http::new().serve_connection(stream, service_fn(cars_handler)).await {
                println!("Error serving connection: {:?}", err);
            }
        });
    }
}

The first thing you will notice is the infinite loop. This is done in this way to constantly listen for requests or incoming connections.

The incoming connections are accepted using the listener.accept() method and stored in a stream variable.

Then, the code spawns a new task (tokio::task:spawn) in which the incoming connection is served to the cars_handler service handler.

Note: Spawning new tasks allows the server to concurrently execute multiple tasks. This means, if your server receives 5 requests, 5 different tasks will run without the need of waiting for another task to complete to start processing a request.

Configure the routes or API endpoints

In this step, you will configure the API endpoints available in the cars_handler function. This server will have an API to perform CRUD on cars:

Request TypePath
GET“/cars”
GET“/cars/:id”
POST“/cars”
API endpoints

Inside the cars_handler function, use the match keyword to conditionally detect the request method (GET,POST,PUT, PATCH,DELETE) and path of the request as a tuple. These patterns will make up for the API endpoint.

async fn cars_handler(req: Request<Body>) -> Result<Response<Body>, Box<dyn Error + Send + Sync>> {
    match (req.method(), req.uri().path()) {
        (&Method::GET, "/cars") => Ok(Response::new(Body::from("GET cars")))

        (&Method::POST, "/cars") => Ok(Response::new(Body::from("POST cars"))),

        // Return the 404 Not Found for other routes.
        _ => {
            let mut not_found = Response::default();
            *not_found.status_mut() = StatusCode::NOT_FOUND;
            Ok(not_found)
        }
    }
}

Notice the previous code uses the match to match the request method (req.method()) and the request path req.uri().path().

Note: In case the client makes a request to a route that doesn’t exist, the API will send a 404 Not Found response.

If you checked the API Endpoints table above, you will notice the path /cars/:id, where :id is the car id parameter of a Car struct you will create in the next steps. Unfortunately, Hyper doesn’t have a built-in method to detect parameters in the path. Hence, it is not possible to define a match pattern like the following:

async fn cars_handler(req: Request<Body>) -> Result<Response<Body>, Box<dyn Error + Send + Sync>> {
    match (req.method(), req.uri().path()) {
        (&Method::GET, "/cars/:id") => Ok(Response::new(Body::from("GET cars")))
    }
}

Technically a client can trigger the request http://127.0.0.1:3000/cars/:id and match the pattern presented in the previous code, but that’s not what you are looking for. You are looking to allow clients to provide the car id instead of :id, .i.e., http://127.0.0.1:3000/cars/2 or http://127.0.0.1:3000/cars/secret_car_id.

To fix that issue, you need to extract the request path and split the path by the slash “/” (since a path could be /cars/1/and/something/else). Splitting the path by “/” generates an array of path_segments .

async fn cars_handler(req: Request<Body>) -> Result<Response<Body>, Box<dyn Error + Send + Sync>> {
    let path = req.uri().path().to_owned();
    let path_segments = path.split("/").collect::<Vec<&str>>();
    let base_path = path_segments[1];

    match (req.method(), base_path) {
        (&Method::GET, "cars") => Ok(Response::new(Body::from("GET cars"))),

        (&Method::POST, "cars") => Ok(Response::new(Body::from("POST cars"))),

        // Return the 404 Not Found for other routes.
        _ => {
            let mut not_found = Response::default();
            *not_found.status_mut() = StatusCode::NOT_FOUND;
            Ok(not_found)
        }
    }
}

Note: Notice the match tuple changed from match (req.method(), req.uri().path()) to match (req.method(), base_path) . Also, the second value of the tuple match patterns no longer include an “/”, such as, (&Method::GET, "cars") or (&Method::POST, "cars").

You might be wondering, What’s the difference using this approach since we are still don’t have a defined path for GET /cars/:id?

You are right.

There isn’t a GET /cars/:id route yet. However, the idea is that GET /cars and GET /cars/:id routes trigger the same event handler ((&Method::GET, "cars") => Ok(Response::new(Body::from("GET cars"))) . Then, based on the values from path_segments, you will programmatically detect whether or not the user provided a car id or :id.

Generate Car Struct and other constant values

This API is about cars. However, there is nothing that represents the structure of a car. Hence, create a Car struct.

#[derive(Serialize, Deserialize)]
struct Car {
    id: String,
    brand: String,
    model: String,
    year: u16,
}

Note: Notice the Car struct uses the attributesSerialize and Deserialize . This allows to convert the Car struct to a string using the serde_json::to_string method. You will see this implementation once you generate the service handlers for each API endpoint.

Also, generate a constant INTERNAL_SERVER_ERROR to store an error message. You will use this constant in case there is an error in the server.

const INTERNAL_SERVER_ERROR: &str = "Internal Server Error";

You will use both, the Car struct and the INTERNAL_SERVER_ERROR constant in the next section as you generate services for each route.

Define services on each route

Now that the routes of the API are defined, it’s time to modify the event handlers.

Generate service for GET “/cars” route

Generate a new function called get_car_list. This will be in charge of returning a response Response<Body> containing an array of Car structs. Typically, these records would come from a database. However, this article hardcodes the records for the sake of simplicity.

fn get_car_list() -> Response<Body> {
    let cars: [Car; 3] = [
        Car {
            id: "1".to_owned(),
            brand: "Ford".to_owned(),
            model: "Bronco".to_owned(),
            year: 2022,
        },
        Car {
            id: "2".to_owned(),
            brand: "Hyundai".to_owned(),
            model: "Santa Fe".to_owned(),
            year: 2010,
        },
        Car {
            id: "3".to_owned(),
            brand: "Dodge".to_owned(),
            model: "Challenger".to_owned(),
            year: 2015,
        },
    ];

    match serde_json::to_string(&cars) {
        Ok(json) => Response::builder()
            .header(header::CONTENT_TYPE, "application/json")
            .body(Body::from(json))
            .unwrap(),
        Err(_) => Response::builder()
            .status(StatusCode::INTERNAL_SERVER_ERROR)
            .body(INTERNAL_SERVER_ERROR.into())
            .unwrap(),
    }
}

Notice the array of Car structs is converted into a string. The reason is because the code generate the response body using the Body::from function. However, the Body::from function accepts a string literal as a parameter and not custom struct.

A match is used as converting the array of Car structs can fail. In the case the serialization to a string fails, it generates an internal server error response.

Now, go back to the cars_handler function. There, find the match pattern (&Method::GET, "cars") and update it with the following logic.

        (&Method::GET, "cars") => {
            if path_segments.len() <= 2 {
                let res = get_car_list();
                return Ok(res);
            }

            let car_id = path_segments[2];

            if car_id.trim().is_empty() {
                let res = get_car_list();
                return Ok(res);
            } else {
                // code to fill whenever path is /cars/:id
            }
        }

This logic helps determine whether the client made the request to the route /cars or to the route /cars/:id.

Notice, there is a section with comments left (// code to fill whenever path is /cars/:id). Later you will update the logic there once you generate the service for the GET /cars/:id endpoint.

Generate service for GET “/cars/:id” route

Create a new function called get_car_by_id . The get_car_by_id will generate a response containing the Car struct values based on the :id the client provided in the request. Once again, this article hardcodes the car records available for the sake of simplicity.

fn get_car_by_id(car_id: &String) -> Response<Body> {
    let cars: [Car; 3] = [
        Car {
            id: "1".to_owned(),
            brand: "Ford".to_owned(),
            model: "Bronco".to_owned(),
            year: 2022,
        },
        Car {
            id: "2".to_owned(),
            brand: "Hyundai".to_owned(),
            model: "Santa Fe".to_owned(),
            year: 2010,
        },
        Car {
            id: "3".to_owned(),
            brand: "Dodge".to_owned(),
            model: "Challenger".to_owned(),
            year: 2015,
        },
    ];

    let car_index_option = cars.iter().position(|x| &x.id == car_id);

    if car_index_option.is_none() {
        return Response::new(Body::from("Car not found"));
    }

    let car = &cars[car_index_option.unwrap()];

    match serde_json::to_string(car) {
        Ok(json) => Response::builder()
            .header(header::CONTENT_TYPE, "application/json")
            .body(Body::from(json))
            .unwrap(),
        Err(_) => Response::builder()
            .status(StatusCode::INTERNAL_SERVER_ERROR)
            .body(INTERNAL_SERVER_ERROR.into())
            .unwrap(),
    }
}

The get_car_by_id check if the car_id exists in any of the array of cars structs. If it doesn’t exist, the function returns a response body with the message “Car not found”.

After you create the get_car_by_id function, go back to the match pattern scope for (&Method::GET, "cars") in the cars_handler function. Then, modify the section with the comments // code to fill whenever path is /cars/:id to call the get_car_by_id and return a Result from the response the get_car_by_id function generates.

let res = get_car_by_id(&car_id.to_string());
Ok(res)

So far, the cars_handler should look like this:

async fn create_car(req: Request<Body>) -> Result<Response<Body>, Box<dyn Error + Send + Sync>> {
    let path = req.uri().path().to_owned();
    let path_segments = path.split("/").collect::<Vec<&str>>();
    let base_path = path_segments[1];

    match (req.method(), base_path) {
        (&Method::GET, "cars") => {
            if path_segments.len() <= 2 {
                let res = get_car_list();
                return Ok(res);
            }

            let car_id = path_segments[2];

            if car_id.trim().is_empty() {
                let res = get_car_list();
                return Ok(res);
            } else {
                let res = get_car_by_id(&car_id.to_string());
                Ok(res)
            }
        }

        (&Method::POST, "cars") => Ok(Response::new(Body::from("POST cars"))),

        // Return the 404 Not Found for other routes.
        _ => {
            let mut not_found = Response::default();
            *not_found.status_mut() = StatusCode::NOT_FOUND;
            Ok(not_found)
        }
    }
}

Generate service for POST “/cars” route

Finally, generate the service associated to the POST /cars route. Similarly to the other services, create a new function called create_car.

This function, contrary to the get_car_by_id and get_car_list functions, will not return just a Response<Body> but a Response<Body> wrapped in a Result. Hence the create_car function definition should look like this.

async fn create_car(req: Request<Body>) -> Result<Response<Body>, Box<dyn Error + Send + Sync>> {

}

Next, add the logic that “creates” a car. This tutorial doesn’t really create a car record in a database. Instead, it uses the request body input the client sent, which should have the shape of a Car struct and populates the struct’s id. Once the struct has an id, the program will send the struct in response body back to the client.

async fn create_car(req: Request<Body>) -> Result<Response<Body>, Box<dyn Error + Send + Sync>> {
    // get the buffer from the request body
    let buffer = hyper::body::aggregate(req).await?;

    // add an id to the new_car
    let mut new_car: serde_json::Value = serde_json::from_reader(buffer.reader())?;

    let mut random = rand::thread_rng();

    let car_id: u8 = random.gen();
    new_car["id"] = serde_json::Value::from(car_id.to_string());
    
    let res = match serde_json::to_string(&new_car) {
        Ok(json) => Response::builder()
            .status(StatusCode::OK)
            .header(header::CONTENT_TYPE, "application/json")
            .body(Body::from(json))
            .unwrap(),
        Err(_) => Response::builder()
            .status(StatusCode::INTERNAL_SERVER_ERROR)
            .body(INTERNAL_SERVER_ERROR.into())
            .unwrap(),
    };

    Ok(res)
}

In the previous code, first, it extracts the request body as a buffer. The buffer contains the Car struct the client sent in the request. However, a person won’t easily understand the request values unless they are deserialized into a JSON format. In the code, the serde_json::from_reader deserializes the buffer into a JSON format, which is stored in the new_car variable.

Then, we generate a random number that serves as the new Car id (new_car["id"] ). After populating the Car id, serialize the new_car to string to generate a Response<Body> .

Then, wrap the Response<Body> in Ok() to satisfy our function definition of returning a Result<Response<Body>>.

Finally, wire up the POST /cars route match pattern back in the cars_handler service handler to trigger the create_car function.

(&Method::POST, "cars") => create_car(req).await

Therefore, the final version of the cars_handler function should look like the following:

async fn cars_handler(req: Request<Body>) -> Result<Response<Body>, Box<dyn Error + Send + Sync>> {
    let path = req.uri().path().to_owned();
    let path_segments = path.split("/").collect::<Vec<&str>>();
    let base_path = path_segments[1];

    match (req.method(), base_path) {
        (&Method::GET, "cars") => {
            if path_segments.len() <= 2 {
                let res = get_car_list();
                return Ok(res);
            }

            let car_id = path_segments[2];

            if car_id.trim().is_empty() {
                let res = get_car_list();
                return Ok(res);
            } else {
                let res = get_car_by_id(&car_id.to_string());
                Ok(res)
            }
        }

        (&Method::POST, "cars") => create_car(req).await,

        // Return the 404 Not Found for other routes.
        _ => {
            let mut not_found = Response::default();
            *not_found.status_mut() = StatusCode::NOT_FOUND;
            Ok(not_found)
        }
    }
}

Test the API

This is the time you’ve been waiting for after developing your API using Hyper.

In a new terminal, run the cargo run command to run the web server. If everything works as expected, you should see a log with the port the server is listening to, which is Listening on http://127.0.0.1:3000.

Running the web server

In a different terminal use curl or a tool like Postman to test the different API endpoints, which are:

  • GET http://127.0.0.1:3000/cars
  • GET http://127.0.0.1:3000/cars/:id
  • POST http://127.0.0.1:3000/cars

If you decide to test using curl, here are the commands to test the previous endpoints respectively:

  • curl http://127.0.0.1:3000/cars
  • curl http://127.0.0.1:3000/cars/4
  • curl http://127.0.0.1:3000/cars/1
  • curl http://127.0.0.1:3000/cars -X POST -d '{"brand": "Mini", "model":"Cooper", "year": 2004 }' -H 'Content-Type: application/json'

Here are the results after running the tests.

Conclusion

This article gave you a comprehensive guide on building an API using Hyper in Rust. Hyper is not the most convenient way to build an API as it doesn’t provide out-of-the-box solutions such as detecting parameters. Interestingly enough, other web server frameworks such as warp are built on Hyper.

Feel free to check out the code in this article in my repository https://github.com/arealesramirez/rust-rest-api-hyper

Are you new to learning Rust?

Learning Rust can be hard and can take a while to get used to the programming language. If you are interested in learning more about this programming language, check out other Rust-related articles on this blog Become A Better Programmer.

Finally, share this article with developers who want a guide to building an API using Hyper in Rest. You can also share your thoughts by replying on Twitter of Become A Better Programmer or to my personal account.