Sending an API request in Rust with Reqwest and Tokio

Gautham Dinesh
6 min readJan 6, 2024

Query an API in Rust

In this article, you will learn to write a function in Rust to make an API call, parse the response, and return the value you need.

Photo by Zdeněk Macháček on Unsplash

We are going to cover a few topics:

  • Using the reqwest library
  • Reading environment variables
  • Error handling
  • JSON parsing

For this tutorial, you will write a program to query the Notion “/search” endpoint to return a single database in the Notion page called “Medium”.

Setup

Set up a Rust project by following this tutorial in the official Rust book.

The first step is to create a Notion integration token by registering an internal Notion integration. The token generated will allow you to make API calls and retrieve data from your page. Save the token in a .env file in the directory of your Rust project.

We will need a few libraries for this demo. Configure your Cargo.toml (Rust’s dependency manager) to look like this:

I will explain what each package does as we go through this tutorial.

Medium Database ID in Notion

Let’s start by writing what we want the main() function to return. In this case, we want to fetch the database ID of the single Medium database on the Notion page.

fn main(){
let medium_db_id = medium_db_id();
}

The medium_db_id() function will search for and return the database ID.

We want to start by loading the integration token you saved. This token is included as an Authorization header in our HTTP request alongside other headers required to access the Notion API endpoint.

use reqwest::header::{HeaderMap, AUTHORIZATION, HeaderValue, HeaderName};

fn medium_db_id() -> () {
dotenv::dotenv().ok();
let notion_integration_token: String = std::env::var("INTEGRATION_TOKEN")
.expect("INTEGRATION_TOKEN must be set in env variables.");

let auth_token: String = format!("Bearer {}", notion_integration_token);

let mut headers = HeaderMap::new();
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&auth_token).expect("Invalid header value"),
);

headers.insert(
HeaderName::from_static("notion-version"), // header name needs to be lowercase
HeaderValue::from_static("2022-06-28"),
);
}

You must import the header components from the HTTP library, reqwest, and the dotenv() function allows us to load the environment variables from our .env file.

One thing to note here is that the HeaderName must be lowercase since there is an implicit conversion happening when using the HeaderName::from_static() function.

The next step is to send a POST request to the Notion API endpoint.

async fn medium_db_id() -> Result<String, reqwest::Error> {

...
...
...

let body = json!({
"query": "Medium",
"filter": {
"value": "database",
"property": "object"
}
}
);

let client = reqwest::Client::new();
let resp = client
.post("https://api.notion.com/v1/search")
.headers(headers)
.json(&body)
.send()
.await?;
}

We define the post request body using the !json macro to convert the string into a serde_json::Value.

We pass this body plus the headers into the post request generated by the reqwest client. The API call is asynchronous since the send() method of the client returns a Future object. When await? is called on this, we execute the Future which returns a Result<Response, Error> object. The “?” terminates the function if an error is returned at this point in execution since we do not want to handle this separately.

There are two changes to the function definition as well. First, we prefixed async the function definition to allow us to execute the medium_db_id() function by calling await since the Future does not need to be processed unless we call the function.

The second change is the return type. We can now expect a successful response in the form of the database id (String, which we are not returning yet) or an error from the reqwest client.

Parsing the JSON response

Let us take a look at how we can handle the response.

"Response":{
"object":"list",
"results":[
{
"object":"database",
"id": "539d5d83-19a6-4987-b127-ef426ce6",
"cover":null,
"icon":null,
"created_time":"2023-12-31T15:54:00.000Z",
"created_by":{
"object":"user",
"id": "bfff5ed3-ace7-4b98-b66b-b7a1bb88"
},
}
]
}

I have stripped down the JSON response to the part we need: the “id” of the “database” object within the results array.

We can parse this Response using a Struct with only the fields we require.

#[derive(Deserialize)]
struct NotionResult {
object: String,
id: String,
}

#[derive(Deserialize)]
struct NotionResponse {
results: Vec<NotionResult>,
}

Deserialize is a macro from Serde that allows us to deserialize the JSON response into this Struct.

match resp.status() {
reqwest::StatusCode::OK => {
let response: NotionResponse = resp.json().await?;
let query_results: Vec<NotionResult> = response.results;
let db_results: Vec<_> = query_results
.iter()
.filter(|r| r.object == "database")
.collect();
}
}

Using our Struct, we can deserialize the OK variant of the response. We then filter the results to extract only the database objects.

Error handling

What if there are other errors that we want to handle?
For example, we need to throw an error if there is more than one database called “Medium”.

We could print it to the console and return nothing but it would be cleaner to return an Err from the function. Let us create a custom Error type for this.

#[derive(Debug)]
enum NotionError {
MultipleDBs(String),
}

impl fmt::Display for NotionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
NotionError::MultipleDBs(msg) => write!(f, "Multiple DBs with name: {}", msg),
}
}
}

impl Error for NotionError {}

Create an Enum that contains the error we would like to return (just one in this case).

Implement the Display trait for our NotionError so that we can print a custom error message to the console.

Finally, the Error trait is implemented for NotionError so that it can returned as an Error from the function.

async fn medium_db_id() -> Result<String, Box<dyn Error>> {

...
...
...

match resp.status() {
reqwest::StatusCode::OK => {
let response: NotionResponse = resp.json().await?;
let query_results: Vec<NotionResult> = response.results;
let db_results: Vec<_> = query_results
.iter()
.filter(|r| r.object == "database")
.collect();

if db_results.len() > 1 {
return Err(Box::new(NotionError::MultipleDBs(
"More than one DB with name Medium".to_string(),
)));
} else {
let medium_db_id = &db_results[0].id;
Ok(medium_db_id.to_owned())
}
}
_ => Err("Unsuccessful response from API endpoint".into()),
}
}

Now, we can return an error message using Box::new to construct a NotionError message. Change the Err return type to Box<dyn Error> to allow you to “dyn”amically return an Error from the function. By using Box, you can still return the reqwest::Error from the API calls since it also implements the Error trait.

We return the ID if everything is Ok (no pun intended).

Finally, in our main() function, we would have to prefix it with async but since the main() function cannot be async in Rust we use the Tokio library to manage the async execution of our code.

#[tokio::main]
async fn main() {
let medium_db_id = medium_db_id().await;
match medium_db_id {
Ok(id) => println!("Medium DB ID: {}", id),
Err(e) => println!("Error: {}", e),
}
}

To summarize, we learned how to make an async API call in Rust, parse the response, handle any errors, and return the output while using Tokio to enable async execution of our main function code.

“A learning curve is essential to growth.” — Tammy Bjelland

You can find the whole code here.

--

--