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:
- Create a new project using
cargo new
- Configure the dependencies using
hyper
andtokio
- Set up the server
- Configure the routes or API endpoints
- Define services on each route
- Test the API
Table of Contents
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. Themain()
function is the first function triggered when running a Rust project usingcargo 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 Type | Path |
---|---|
GET | “/cars” |
GET | “/cars/:id” |
POST | “/cars” |
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.
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/carsGET
http://127.0.0.1:3000/cars/:idPOST
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.
- How to Print a Raw Pointer in Rust?
- Rust | (Solved) “Missing type for `const` or `static`”
- What is the Meaning of the Asterisk * Symbol in Rust?
- (Solved) error: toolchain ‘nightly-x86_64-pc-windows-msvc’
- Rust | What Is The Difference Between Copy and Clone Trait?
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.