How to build an API server with Rust

A step-by-step tutorial for building a scalable Rust HTTP server using Actix and MongoDB

Published on
Apr 1, 2024

Read time
16 min read

Introduction

In this article, we’ll walk through how to create an API server for the web using Rust and Actix, with MongoDB for our database.

Why Rust?

When it comes to building modern API servers, the web community seems most excited about three languages: TypeScript, Go and Rust. Of these, TypeScript is typically considered the best for productivity but the worst for performance; Rust is considered the worst for productivity but the best for speed; and Go is somewhere in the middle for both metrics.

I work with TypeScript every day, so I certainly feel productive using it. But I’m also aware of the language’s shortcomings. So why choose Rust?

Type Safety

In particular, when working with REST APIs in TypeScript, it can be tempting to skimp on the details and not provide exhaustive types for expected request bodies or third party data sources.

Rust makes it much harder to skimp on the details, as it forces you to provide complete data structures for any data type — or to very consciously opt-out of this responsibility, by using something like the Value type from the serde_json crate.

Execution Speed

The language offers other benefits, too. Rust is fast, nearing or matching the performance of C and C++.

Because of its execution speed and memory safety, plus the lack of overhead of garbage collection, it’s typically more efficient to scale a Rust program vertically than a Node.js program, which can help keep the cost and complexity of infrastructure lower.

At the time of writing, Rust is the most common language used by the Top 20 HTTP frameworks in this benchmark. In this article, we’re going to be using one of these frameworks, Actix, which is very fast and also a popular choice with a decent-sized community. If you combine the speed of the framework with the inherent speed of Rust for data processing, you’re likely to end up with a very fast API.

Productivity?

Aside from working out how to write an API in Rust, I also wanted to answer another question: did it feel less productive than TypeScript? This is harder to measure, as I am comparing a language I am very comfortable with against one I am still learning.

Building this, I really haven’t felt that much slower than I would be using TypeScript. Setting up Actix is certainly more complex than setting up something like Express.js. In general, asynchronous code is harder in Rust than in TypeScript.

But surprisingly, working with MongoDB felt — in some ways — easier in Rust than in TypeScript, as there’s no need for extra libraries like Mongoose and Typegoose. At the same time, handling the conversion from the request format to the database format did add an extra layer of complexity there. Overall, there’s definitely more to think about in a Rust implementation. But perhaps the productivity gains come in the long term, if our code requires less maintenance!

Why MongoDB?

I’m choosing MongoDB for our database mainly because I find it’s a bit quicker to set-up than the main SQL alternatives, like PostgreSQL. We want to focus on writing Rust, not setting up a database!

That said, I think MongoDB could be a good choice for Rust because its strict type system brings safety and predictability to Mongo’s schemaless design; we can write our types in Rust without having to double up our work on the database layer.

If you want to follow along and need a hand setting up MongoDB, check out the official installation guide.

What We’re Building

We’ll be creating an API to help manage dog walking bookings. My girlfriend recently started a dog walking business on the side and so, naturally, she needs a Rust web server. I know, I know. I’m such a romantic.

Rust is, of course, overkill for this particular task. But my point is to demonstrate how we can set-up the foundations scalable architecture for a REST API.

You can find the final, working version of the project here.

Before diving into the code, let’s plan out what types of data we want to store and what the main functionality should be.

We’ll need to store three main types of data:

  • Dogs — we need to know each pet’s name, age and breed.
  • Owners — we’ll want each owner’s name, address and some contact details.
  • Bookings — we’ll need to know the start time and duration of each booking, as well as the owner who the booking is for.

What about the main functionality? For our first pass, we’ll keep things simple and focus on the following endpoints.

  • POST /owner— for adding an owner.
  • POST /dog — for adding a dog to the given owner.
  • POST /booking — for adding a booking for the given owner.
  • GET /bookings — to get all the future bookings, from closest in time to furthest away.
  • PUT /booking/{id}/cancel — to cancel a particular booking that’s fallen through.

This should give us enough functionality to begin with and we can add new functionality, such as updating or deleting records, once the bookings start rolling in!

Let’s get programming.

Installing Rust

To follow along, ensure you have Rust and Cargo installed on your system. You can check by running:

rustc --version
cargo --version

If Rust is not installed, follow the instructions on the official Rust website.

Creating a New Project

Let’s start by using cargo to scaffold a new project:

cargo new rust-web-server-tutorial

Then, open up Cargo.toml and add the following dependencies:

[dependencies]
actix-web = "4"
chrono = "0.4.37"
futures-util = "0.3.30"
mongodb = "2.8.2"
serde = "1.0.197"

We’ve already spoken about why we’re using Actix and MongoDB. This is what the other dependencies are for:

  • We’ll use the chrono crate to parse a string into a date format.
  • The futures-util crate will help us work with MongoDB’s Cursor struct, which implements the Stream trait, and which we get when fetching multiple documents.
  • As for serde, this is a very popular crate used for serializing and deserializing Rust data structures; it allows us to convert structures into a format that we can send over the network and convert data from the network into our Rust data structures.

Let’s add some new boilerplate to our entrypoint file main.rs so that the main function returns an Actix HTTP server and binds it to http://localhost:5001.

use actix_web::{get, App, HttpResponse, HttpServer, Responder};

#[get("/")]
async fn hello() -> impl Responder {
    HttpResponse::Ok().body("Hello Medium!")
}

#[actix_web::main]
async fn main() -> std::io::Result() {
    HttpServer::new(|| App::new().service(hello))
        .bind(("localhost", 5001))?
        .run()
        .await
}

Here you can see that we’re running an HTTP server with one route. We define the route method and path using Actix macros. In this case, get and the root path "/".

Finally, let’s run our project. I like to develop my Rust projects using the following command:

cargo watch -c -w src -x run

Here’s what the configuration flags do:

  • The -c or -clear flag clears the screen between each change.
  • -w src or --watch src tells cargo watch to watch just the files in the src directory.
  • -x run means that the watch command only runs when a change is detected.

Now, if you visit http://localhost:5001, you should see the text "Hello Medium!".

Testing and Debugging

If you are newer to Rust, much more time is spent satisfying the compiler than in most other languages. But typically, this gets you surprisingly far!

If, as you’re following along, you want to run a particular function, import it into main.rs and call it inside the main function. I have added the Debug trait to all the data structures, so you can use the dbg! macro to see the contents of most variables.

Testing endpoints can be done with cURL or software like Postman or Insomnia. I have included some sample cURL commands towards the bottom of this article.

Setting Up the Filesystem

We’re going to split our program into three main areas:

  • models is where we’ll define the data structures used in the database and our HTTP requests.
  • routes is where we’ll define the method and path of our endpoints, and convert the request JSON into our data structures.
  • services is where we’ll initialise our database code and store the logic for interacting with the database, which can be called from our routes.

Our filesystem should look like this.

rust-web-server-tutorial/
└── src/
    ├── main.rs
    ├── models/
    │   ├── booking_model.rs
    │   ├── dog_model.rs
    │   ├── mod.rs
    │   └── owner_model.rs
    ├── routes/
    │   ├── booking_route.rs
    │   ├── dog_route.rs
    │   ├── mod.rs
    │   └── owner_route.rs
    └── services/
        ├── db.rs
        └── mod.rs

To set this up quickly, run the following shell command in the project root:

cd src && \
mkdir models routes services && \
touch models/booking_model.rs models/dog_model.rs models/mod.rs models/owner_model.rs && \
touch routes/booking_route.rs routes/dog_route.rs routes/mod.rs routes/owner_route.rs && \
touch services/db.rs services/mod.rs && \
cd ..

Defining Our Models

To begin with, we’ll define the shape of the data we want in the database, as well as what we’ll support in the request body of our endpoints.

Booking Model

Let’s start with our Booking model.

// booking_model.rs
use mongodb::bson::{oid::ObjectId, DateTime};
use serde::{Deserialize, Serialize};

#[derive(Debug, Deserialize, Serialize)]
pub struct Booking {
    pub _id: ObjectId,
    pub owner: ObjectId,
    pub start_time: DateTime,
    pub duration_in_minutes: u8,
    pub cancelled: bool,
}

This represents what we want to end up in our database. However, we want our end users to format the body of their HTTP requests differently. For a start, we’re not expecting users to bring their own _id when creating a new booking, but we also want to simplify the types, allowing end users to pass a String for the owner and the start_time.

To achieve this, we’ll create a BookingRequest struct and also write the logic to convert from BookingRequest to Booking by implementing a TryFrom trait.

// booking_model.rs
use std::{convert::TryFrom, time::SystemTime};

use chrono::Utc;
use mongodb::bson::{oid::ObjectId, DateTime};
use serde::{Deserialize, Serialize};

#[derive(Debug, Deserialize, Serialize)]
pub struct Booking {
    pub _id: ObjectId,
    pub owner: ObjectId,
    pub start_time: DateTime,
    pub duration_in_minutes: u8,
    pub cancelled: bool,
}

#[derive(Debug, Deserialize)]
pub struct BookingRequest {
    pub owner: String,
    pub start_time: String,
    pub duration_in_minutes: u8,
}

impl TryFromBookingRequest for Booking {
    type Error = Boxdyn std::error::Error;

    fn try_from(item: BookingRequest) -> ResultSelf, Self::Error {
        let chrono_datetime: SystemTime = chrono::DateTime::parse_from_rfc3339(&item.start_time)
            .map_err(|err| format!("Failed to parse start_time: {}", err))?
            .with_timezone(&Utc)
            .into();

        Ok(Self {
            _id: ObjectId::new(),
            owner: ObjectId::parse_str(&item.owner).expect("Failed to parse owner"),
            start_time: DateTime::from(chrono_datetime),
            duration_in_minutes: item.duration_in_minutes,
            cancelled: false,
        })
    }
}

Note that we should use TryFrom rather than From, because with third party data there’s always a good chance the conversion could fail, and From must may infallible.

Our Error type Boxdyn std::error::Error is also worth noting. This is a trait object that can hold any type that implements the std::error::Error trait. It’s a very general error type that can represent many different kinds of errors and is useful in situations where a function could fail in multiple ways without having to handle each possible failure manually.

We’re not done yet!

Looking ahead, we know that we need an endpoint that returns a booking with all the related owner and dog data. We’ll call that FullBooking and add that to our file, so the whole thing looks like this:

// booking_model.rs
use std::{convert::TryFrom, time::SystemTime};

use super::{dog_model::Dog, owner_model::Owner};
use chrono::Utc;
use mongodb::bson::{oid::ObjectId, DateTime};
use serde::{Deserialize, Serialize};

#[derive(Debug, Deserialize, Serialize)]
pub struct Booking {
    pub _id: ObjectId,
    pub owner: ObjectId,
    pub start_time: DateTime,
    pub duration_in_minutes: u8,
    pub cancelled: bool,
}

#[derive(Debug, Deserialize)]
pub struct BookingRequest {
    pub owner: String,
    pub start_time: String,
    pub duration_in_minutes: u8,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct FullBooking {
    pub _id: ObjectId,
    pub owner: Owner,
    pub dogs: VecDog,
    pub start_time: DateTime,
    pub duration_in_minutes: u8,
    pub cancelled: bool,
}

impl TryFromBookingRequest for Booking {
    type Error = Boxdyn std::error::Error;

    fn try_from(item: BookingRequest) -> ResultSelf, Self::Error {
        let chrono_datetime: SystemTime = chrono::DateTime::parse_from_rfc3339(&item.start_time)
            .map_err(|err| format!("Failed to parse start_time: {}", err))?
            .with_timezone(&Utc)
            .into();

        Ok(Self {
            _id: ObjectId::new(),
            owner: ObjectId::parse_str(&item.owner).expect("Failed to parse owner"),
            start_time: DateTime::from(chrono_datetime),
            duration_in_minutes: item.duration_in_minutes,
            cancelled: false,
        })
    }
}

Now we understand the pattern, we can implement the other models, which are simpler!

Next, let’s define the Owner model.

Owner Model

Our customers may not always supply an email, so that is wrapped in the Option type. But we need to know where they live and have a phone number in case of emergency, so those types are required.

This data structure has an ObjectId too, so we’ll also need to handle conversion from the request type.

// owner_model.rs
use mongodb::bson::oid::ObjectId;
use serde::{Deserialize, Serialize};
use std::convert::TryFrom;

#[derive(Debug, Serialize, Deserialize)]
pub struct Owner {
    pub _id: ObjectId,
    pub name: String,
    pub email: OptionString,
    pub phone: String,
    pub address: String,
}

#[derive(Debug, Deserialize)]
pub struct OwnerRequest {
    pub name: String,
    pub email: OptionString,
    pub phone: String,
    pub address: String,
}

impl TryFromOwnerRequest for Owner {
    type Error = Boxdyn std::error::Error;

    fn try_from(item: OwnerRequest) -> ResultSelf, Self::Error {
        Ok(Self {
            _id: ObjectId::new(),
            name: item.name,
            email: item.email,
            phone: item.phone,
            address: item.address,
        })
    }
}

Dog Model

Lastly, we can implement our Dog model.

// dog_model.rs
use mongodb::bson::oid::ObjectId;
use serde::{Deserialize, Serialize};

#[derive(Debug, Deserialize, Serialize)]
pub struct Dog {
    pub _id: ObjectId,
    pub owner: ObjectId,
    pub name: OptionString,
    pub age: Optionu8,
    pub breed: OptionString,
}

#[derive(Debug, Deserialize)]
pub struct DogRequest {
    pub owner: String,
    pub name: OptionString,
    pub age: Optionu8,
    pub breed: OptionString,
}

impl TryFromDogRequest for Dog {
    type Error = Boxdyn std::error::Error;

    fn try_from(item: DogRequest) -> ResultSelf, Self::Error {
        Ok(Self {
            _id: ObjectId::new(),
            owner: ObjectId::parse_str(&item.owner).expect("Failed to parse owner"),
            name: item.name,
            age: item.age,
            breed: item.breed,
        })
    }
}

Don’t forget to make these models public in mod.rs, which will turn our models folder into a module we can import elsewhere.

// mod.rs
pub mod booking_model;
pub mod dog_model;
pub mod owner_model;

Make sure to add mod models to the top of your main.rs file so this module can then be accessed by other directories.

Adding a Database Service

To handle the database connection, collections and database methods, let’s go into db.rs inside the services folder. As our project grows, we could split this out into multiple smaller files, but for now a single file seems easier to start with.

Defining and Initialising our Database

First, let’s add a struct for out Database.

// db.rs
use crate::models::booking_model::{Booking, FullBooking};
use crate::models::dog_model::Dog;
use crate::models::owner_model::Owner;

pub struct Database {
    booking: CollectionBooking,
    dog: CollectionDog,
    owner: CollectionOwner,
}

Next, we’ll add an init method to connect to the database using the MONGO_URI environment variable, if it is available, or a local connection string if it isn’t.

// db.rs
use std::env;

use mongodb::{Client, Collection};

use crate::models::booking_model::Booking;
use crate::models::dog_model::Dog;
use crate::models::owner_model::Owner;

pub struct Database {
    booking: CollectionBooking,
    dog: CollectionDog,
    owner: CollectionOwner,
}

impl Database {
    pub async fn init() -> Self {
        let uri = match env::var("MONGO_URI") {
            Ok(v) => v.to_string(),
            Err(_) => "mongodb://localhost:27017/?directConnection=true".to_string(),
        };

        let client = Client::with_uri_str(uri).await.unwrap();
        let db = client.database("dog_walking");

        let booking: CollectionBooking = db.collection("booking");
        let dog: CollectionDog = db.collection("dog");
        let owner: CollectionOwner = db.collection("owner");

        Database {
            booking,
            dog,
            owner,
        }
    }
}

For our database methods, we’ll be relying on the mongodb crate.

Create Functions

We’ll need three separate methods to create an owner, dog and booking.

// db.rs

impl Database {
    // other functions
    pub async fn create_owner(&self, owner: Owner) -> ResultInsertOneResult, Error {
        let result = self
            .owner
            .insert_one(owner, None)
            .await
            .ok()
            .expect("Error creating owner");

        Ok(result)
    }

    pub async fn create_dog(&self, dog: Dog) -> ResultInsertOneResult, Error {
        let result = self
            .dog
            .insert_one(dog, None)
            .await
            .ok()
            .expect("Error creating dog");

        Ok(result)
    }

    pub async fn create_booking(&self, booking: Booking) -> ResultInsertOneResult, Error {
        let result = self
            .booking
            .insert_one(booking, None)
            .await
            .ok()
            .expect("Error creating booking");

        Ok(result)
    }
}

Cancellation

Our cancellation method is also straightforward. We’ll take an argument of booking_id and simply update the value of cancelled to false.

// db.rs

impl Database {
    // other functions

    pub async fn cancel_booking(&self, booking_id: &str) -> ResultUpdateResult, Error {
        let result = self
            .booking
            .update_one(
                doc! {
                    "_id": ObjectId::from_str(booking_id).expect("Failed to parse booking_id")
                },
                doc! {
                    "$set": doc! {
                        "cancelled": true
                    }
                },
                None,
            )
            .await
            .ok()
            .expect("Error cancelling booking");

        Ok(result)
    }
}

Fetching the Full Bookings

Our final method, to fetch the full booking data together with the relevant owner and dogs, is a little more complex. We can use MongoDB’s aggregation operations to perform lookups on the relevant connections, so all that processing can be done in a single database operation.

This isn’t a MongoDB tutorial, so I won’t go into the details here; what you need to know if that we’re filtering by resources that are in the future and are not cancelled, then we’re finding the owner and dogs relevant to that booking.

// db.rs

impl Database {
    // other functions

    pub async fn get_bookings(&self) -> ResultVec<FullBooking, Error> {
        let now: SystemTime = Utc::now().into();

        let mut results = self
            .booking
            .aggregate(
                vec![
                    doc! {
                        "$match": {
                            "cancelled": false,
                            "start_time": {
                                "$gte": DateTime::from_system_time(now)
                            }
                        }
                    },
                    doc! {
                        "$lookup": doc! {
                            "from": "owner",
                            "localField": "owner",
                            "foreignField": "_id",
                            "as": "owner"
                        }
                    },
                    doc! {
                        "$unwind": doc! {
                            "path": "$owner"
                        }
                    },
                    doc! {
                        "$lookup": doc! {
                            "from": "dog",
                            "localField": "owner._id",
                            "foreignField": "owner",
                            "as": "dogs"
                        }
                    },
                ],
                None,
            )
            .await
            .ok()
            .expect("Error getting bookings");

        let mut bookings: VecFullBooking = Vec::new();

        while let Some(result) = results.next().await {
            match result {
                Ok(doc) => {
                    let booking: FullBooking =
                        from_document(doc).expect("Error converting document to FullBooking");
                    bookings.push(booking);
                }
                Err(err) => panic!("Error getting booking: {}", err),
            }
        }

        Ok(bookings)
    }
}

Aggregations return a type of CursorDocument. To convert this into our desired return type of VecFullBooking, we’ll need to import futures_utl::stream::StreamExt which allows us to iterate over the returned documents using the next method.

Combining all the above methods, our db.rs file now looks like this:

// db.rs
use std::{env, str::FromStr, time::SystemTime};

use chrono::Utc;
use futures_util::stream::StreamExt;
use mongodb::{
    bson::{doc, extjson::de::Error, from_document, oid::ObjectId, DateTime},
    results::{InsertOneResult, UpdateResult},
    Client, Collection,
};

use crate::models::booking_model::{Booking, FullBooking};
use crate::models::dog_model::Dog;
use crate::models::owner_model::Owner;

pub struct Database {
    booking: CollectionBooking,
    dog: CollectionDog,
    owner: CollectionOwner,
}

impl Database {
    pub async fn init() -> Self {
        let uri = match env::var("MONGO_URI") {
            Ok(v) => v.to_string(),
            Err(_) => "mongodb://localhost:27017/?directConnection=true".to_string(),
        };

        let client = Client::with_uri_str(uri).await.unwrap();
        let db = client.database("dog_walking");

        let booking: CollectionBooking = db.collection("booking");
        let dog: CollectionDog = db.collection("dog");
        let owner: CollectionOwner = db.collection("owner");

        Database {
            booking,
            dog,
            owner,
        }
    }

    pub async fn create_owner(&self, owner: Owner) -> ResultInsertOneResult, Error {
        let result = self
            .owner
            .insert_one(owner, None)
            .await
            .ok()
            .expect("Error creating owner");

        Ok(result)
    }

    pub async fn create_dog(&self, dog: Dog) -> ResultInsertOneResult, Error {
        let result = self
            .dog
            .insert_one(dog, None)
            .await
            .ok()
            .expect("Error creating dog");

        Ok(result)
    }

    pub async fn create_booking(&self, booking: Booking) -> ResultInsertOneResult, Error {
        let result = self
            .booking
            .insert_one(booking, None)
            .await
            .ok()
            .expect("Error creating booking");

        Ok(result)
    }

    pub async fn get_bookings(&self) -> ResultVec<FullBooking, Error> {
        let now: SystemTime = Utc::now().into();

        let mut results = self
            .booking
            .aggregate(
                vec![
                    doc! {
                        "$match": {
                            "cancelled": false,
                            "start_time": {
                                "$gte": DateTime::from_system_time(now)
                            }
                        }
                    },
                    doc! {
                        "$lookup": doc! {
                            "from": "owner",
                            "localField": "owner",
                            "foreignField": "_id",
                            "as": "owner"
                        }
                    },
                    doc! {
                        "$unwind": doc! {
                            "path": "$owner"
                        }
                    },
                    doc! {
                        "$lookup": doc! {
                            "from": "dog",
                            "localField": "owner._id",
                            "foreignField": "owner",
                            "as": "dogs"
                        }
                    },
                ],
                None,
            )
            .await
            .ok()
            .expect("Error getting bookings");

        let mut bookings: VecFullBooking = Vec::new();

        while let Some(result) = results.next().await {
            match result {
                Ok(doc) => {
                    let booking: FullBooking =
                        from_document(doc).expect("Error converting document to FullBooking");
                    bookings.push(booking);
                }
                Err(err) => panic!("Error getting booking: {}", err),
            }
        }

        Ok(bookings)
    }

    pub async fn cancel_booking(&self, booking_id: &str) -> ResultUpdateResult, Error {
        let result = self
            .booking
            .update_one(
                doc! {
                    "_id": ObjectId::from_str(booking_id).expect("Failed to parse booking_id")
                },
                doc! {
                    "$set": doc! {
                        "cancelled": true
                    }
                },
                None,
            )
            .await
            .ok()
            .expect("Error cancelling booking");

        Ok(result)
    }
}

We’ll also need a small mod.rs file to expose our service.

// mod.rs
pub mod db;

We now have all the functionality needed to interact with our database. All that’s left is to define our routes!

Adding HTTP Routes

We’re ready to start writing our endpoints!

By now, we’ve done most of the hard work. Each of our routes can call its own specific function in the Database service, and using Actix macros makes it easy to specify the method and path we want for each route.

Dog Route

Apart from specifying the method and path of the route, we need to clone the fields from our JSON request into our request struct for the given model.

// dog_route.rs
use crate::{
    models::dog_model::{Dog, DogRequest},
    services::db::Database,
};
use actix_web::{
    post,
    web::{Data, Json},
    HttpResponse,
};

#[post("/dog")]
pub async fn create_dog(db: DataDatabase, request: JsonDogRequest) -> HttpResponse {
    match db
        .create_dog(
            Dog::try_from(DogRequest {
                owner: request.owner.clone(),
                name: request.name.clone(),
                age: request.age.clone(),
                breed: request.breed.clone(),
            })
            .expect("Error converting DogRequest to Dog."),
        )
        .await
    {
        Ok(booking) => HttpResponse::Ok().json(booking),
        Err(err) => HttpResponse::InternalServerError().body(err.to_string()),
    }
}

Owner Route

For the owner route, we’ll follow the same pattern.

// owner_route.rs
use crate::{
    models::owner_model::{Owner, OwnerRequest},
    services::db::Database,
};
use actix_web::{
    post,
    web::{Data, Json},
    HttpResponse,
};

#[post("/owner")]
pub async fn create_owner(db: DataDatabase, request: JsonOwnerRequest) -> HttpResponse {
    match db
        .create_owner(
            Owner::try_from(OwnerRequest {
                name: request.name.clone(),
                email: request.email.clone(),
                phone: request.phone.clone(),
                address: request.address.clone(),
            })
            .expect("Error converting OwnerRequest to Owner."),
        )
        .await
    {
        Ok(booking) => HttpResponse::Ok().json(booking),
        Err(err) => HttpResponse::InternalServerError().body(err.to_string()),
    }
}

Booking Route

Bookings require a bit more work, as we’re supporting three different endpoints. To start with, our create endpoint will follow the same pattern as those above.

// booking_route.rs
use crate::{
    models::booking_model::{Booking, BookingRequest},
    services::db::Database,
};
use actix_web::{
    get, post, put,
    web::{Data, Json, Path},
    HttpResponse,
};

#[post("/booking")]
pub async fn create_booking(db: DataDatabase, request: JsonBookingRequest) -> HttpResponse {
    match db
        .create_booking(
            Booking::try_from(BookingRequest {
                owner: request.owner.clone(),
                start_time: request.start_time.clone(),
                duration_in_minutes: request.duration_in_minutes.clone(),
            })
            .expect("Error converting BookingRequest to Booking."),
        )
        .await
    {
        Ok(booking) => HttpResponse::Ok().json(booking),
        Err(err) => HttpResponse::InternalServerError().body(err.to_string()),
    }
}

The fetch endpoint is even simpler, as we have no JSON body to parse.

// booking_route.rs

// existing code

#[get("/bookings")]
pub async fn get_bookings(db: DataDatabase) -> HttpResponse {
    match db.get_bookings().await {
        Ok(bookings) => HttpResponse::Ok().json(bookings),
        Err(err) => HttpResponse::InternalServerError().body(err.to_string()),
    }
}

Finally, we need an update route to cancel our bookings. We’ll use add a dynamic id into the path, which needs to be extracted before we can call our db.cancel_booking function.

// booking_route.rs

// existing code

#[put("/booking/{id}/cancel")]
pub async fn cancel_booking(db: DataDatabase, path: Path(String,)) -> HttpResponse {
    let id = path.into_inner().0;

    match db.cancel_booking(id.as_str()).await {
        Ok(result) => HttpResponse::Ok().json(result),
        Err(err) => HttpResponse::InternalServerError().body(err.to_string()),
    }
}

Altogether, the file should look like this:

// booking_route.rs
use crate::{
    models::booking_model::{Booking, BookingRequest},
    services::db::Database,
};
use actix_web::{
    get, post, put,
    web::{Data, Json, Path},
    HttpResponse,
};

#[get("/bookings")]
pub async fn get_bookings(db: DataDatabase) -> HttpResponse {
    match db.get_bookings().await {
        Ok(bookings) => HttpResponse::Ok().json(bookings),
        Err(err) => HttpResponse::InternalServerError().body(err.to_string()),
    }
}

#[put("/booking/{id}/cancel")]
pub async fn cancel_booking(db: DataDatabase, path: Path(String,)) -> HttpResponse {
    let id = path.into_inner().0;

    match db.cancel_booking(id.as_str()).await {
        Ok(result) => HttpResponse::Ok().json(result),
        Err(err) => HttpResponse::InternalServerError().body(err.to_string()),
    }
}

#[post("/booking")]
pub async fn create_booking(db: DataDatabase, request: JsonBookingRequest) -> HttpResponse {
    match db
        .create_booking(
            Booking::try_from(BookingRequest {
                owner: request.owner.clone(),
                start_time: request.start_time.clone(),
                duration_in_minutes: request.duration_in_minutes.clone(),
            })
            .expect("Error converting BookingRequest to Booking."),
        )
        .await
    {
        Ok(booking) => HttpResponse::Ok().json(booking),
        Err(err) => HttpResponse::InternalServerError().body(err.to_string()),
    }
}

Make sure to add each of our route files to mod.rs.

// mod.rs
pub mod booking_route;
pub mod dog_route;
pub mod owner_route;

Bringing It All Together

At last, we can hook up our routes into our HTTP server and try them out! Back in main.rs we need to add a service call for each route, like so:

// main.rs
mod models;
mod routes;
mod services;

use actix_web::{get, web::Data, App, HttpResponse, HttpServer, Responder};
use routes::{
    booking_route::{cancel_booking, create_booking, get_bookings},
    dog_route::create_dog,
    owner_route::create_owner,
};
use services::db::Database;

#[get("/")]
async fn hello() -> impl Responder {
    HttpResponse::Ok().body("Hello Medium!")
}

#[actix_web::main]
async fn main() -> std::io::Result() {
    let db = Database::init().await;
    let db_data = Data::new(db);
    HttpServer::new(move || {
        App::new()
            .app_data(db_data.clone())
            .service(hello)
            .service(create_owner)
            .service(create_dog)
            .service(create_booking)
            .service(get_bookings)
            .service(cancel_booking)
    })
    .bind(("127.0.0.1", 5001))?
    .run()
    .await
}

We’ve completed the goals we set for ourselves! But how do we know if it works? We can use a tool like Postman or cURL to try out our endpoints. Below, here’s some cURL code to get you started.

## POST /owner
curl --location '[http://localhost:5001/owner](http://localhost:5001/owner)' \
--header 'Content-Type: application/json' \
--data-raw '{
    "name": "Joe Bloggs",
    "email": "joe.bloggs@example.org",
    "phone": "+44800001066",
    "address": "123 Main St"
}'

## POST /dog
curl --location '[http://localhost:5001/dog](http://localhost:5001/dog)' \
--header 'Content-Type: application/json' \
--data '{
    "owner": "66080390d0e4f489a8e0bbd0",
    "name": "Chuffey",
    "age": 7,
    "breed": "Miniature Schnauzer"
}'

## POST /booking
curl --location '[http://localhost:5001/booking](http://localhost:5001/booking)' \
--header 'Content-Type: application/json' \
--data '{
    "owner": "66080390d0e4f489a8e0bbd0",
    "start_time": "2024-04-30T10:00:00.000Z",
    "duration_in_minutes": 30
}'

## GET /bookings
curl --location '[http://localhost:5001/bookings](http://localhost:5001/bookings)'

## PUT /booking/{id}/cancel
curl --location --request PUT '[http://localhost:5001/booking/66080390d0e4f489a8e0bbd0/cancel](http://localhost:5001/booking/66080390d0e4f489a8e0bbd0/cancel)'

That’s a wrap! If you’d like to check out a repository containing the finished project, click the following link:

GitHub - BretCameron/rust-web-server-tutorial
https://github.com/BretCameron/rust-web-server-tutorial

I hope you found this article useful. There are many possible ways to build an API server in Rust and there are lots of other ways to tackle the same problem!

Finally, to see a video version of this tutorial, click below. This is my first attempt at a how-to video, so I’d appreciate any feedback!

© 2024 Bret Cameron