Rust ๐Ÿฆ€ CRUD Rest API

Rust ๐Ÿฆ€ CRUD Rest API

Rust, Postgres, Docker, Docker Compose

ยท

16 min read

Featured on Hashnode

Let's create a CRUD Rest API in Rust using:

  • No specific framework

  • Serde to serialize and deserialize JSON

  • Postgres (database)

  • Docker

  • Docker Compose

If you prefer a video version:

All the code is available in the GitHub repository (link in the video description): youtu.be/vhNoiBOuW94


๐Ÿ Intro

Here is a schema of the architecture of the application we are going to create:

PHP CRUD Rest API with Laravel, Postgres, Docker and Docker Compose. Postman and Tableplus to test it

We will create five endpoints for basic CRUD operations:

  • Create

  • Read all

  • Read one

  • Update

  • Delete

We will use Postgres as our database and Docker and Docker Compose to run the application.

We will use Postman to test the endpoints and Tableplus to check the database.

๐Ÿ‘ฃ Steps

We will go with a step-by-step guide so that you can follow along.

Here are the steps:

  1. Check the prerequisites

  2. Project creation and dependency installation

  3. Code the application

  4. Run the Postgres database with Docker

  5. Build and run the application with Docker Compose

  6. Test the application with Postman and TablePlus


๐Ÿ’ก Prerequisites

  • Rust compiler installed (version 1.51+)

  • cargo installed (version 1.51+)

  • docker installed (version 20.10+ )

  • [optional] VS Code installed (or any IDE you prefer)

  • [optional] Postman or any API test tool

  • [optional] Tableplus or any database client


๐Ÿš€ Create a new Rust project

To create a new Rust project, we will use the CLI.

cargo new rust-crud-api

Step inside the project folder:

cd rust-crud-api

And open the project with your favorite IDE. If you use VS Code, you can use the following command:

code .

Open the file called Cargo.toml and add the following dependencies:

postgres = "0.19"
serde = "1.0"
serde_json = "1.0"
serde_derive = "1.0"

postgres is the Postgres driver for Rust. serde is a library to serialize and deserialize. serde_json is a library specific for JSON. serde_derive is a library to derive the Serialize and Deserialize traits (macro)

Your Cargo.toml file should look like this:

[package]
name = "rust-crud-api"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
postgres = "0.19"
serde = "1.0"
serde_json = "1.0"
serde_derive = "1.0"

Please notice that the Package name could differ based on the name you gave to your project.

Your project should now look like this:

Rust project structure

We are now ready to code the application.


๐Ÿ‘ฉโ€๐Ÿ’ป Code the application

We will go step by step:

  1. Import the dependencies.

  2. Create the model (a user with Id, name, and email) and add constants.

  3. Main function: database connection and TCP server.

  4. Utility functions: set_database, get_id, get_user_request_body.

  5. Create the routes in a function (endpoints).

  6. Create utility functions.

  7. Create the controllers.

For this project, we will code everything in a single file of ~200 lines of code.

This is not a best practice, but it will help us focus on the Rust code, not the project structure.

All the code is available on GitHub (link in the video description).

โฌ‡๏ธ Import the dependencies

Open the main.rs file, delete all the code, and add the following imports:

use postgres::{ Client, NoTls };
use postgres::Error as PostgresError;
use std::net::{ TcpListener, TcpStream };
use std::io::{ Read, Write };
use std::env;

#[macro_use]
extern crate serde_derive;
  • Client is used to connect to the database.

  • NoTls is used to connect to the database without TLS.

  • PostgresError is the error type returned by the Postgres driver.

  • TcpListener and TcpStream to create a TCP server.

  • Read and Write are used to read and write from a TCP stream.

  • env is used to read the environment variables.

the #[macro_use] attribute is used to import the serde_derive macro.

We will use it to derive our model's Serialize and Deserialize traits.

๐Ÿฅป Create the model

Just below the imports, add the following code:

//Model: User struct with id, name, email
#[derive(Serialize, Deserialize)]
struct User {
    id: Option<i32>,
    name: String,
    email: String,
}

We will use this model to represent a user in our application.

  • id is an integer and is optional. The reason is that we don't provide the id when we create or update a new user. The database will generate it for us. But we still want to return the user with an id when we get them.

  • name is a string, and it is mandatory. We will use it to store the name of the user.

email is a string, and it is mandatory. We will use it to store the user's email (there is no check if it's a valid email).

๐Ÿชจ Constants

Just below the model, add the following constants:

//DATABASE URL
const DB_URL: &str = env!("DATABASE_URL");

//cosntants
const OK_RESPONSE: &str = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n";
const NOT_FOUND: &str = "HTTP/1.1 404 NOT FOUND\r\n\r\n";
const INTERNAL_ERROR: &str = "HTTP/1.1 500 INTERNAL ERROR\r\n\r\n";
  • DB_URL is the URL of the database. We will read it from the environment variables. In this case, we add the header Content-Type: application/json to the response.

  • OK_RESPONSE, NOT_FOUND, and INTERNAL_ERROR are the responses we will send back to the client. We will use them to return the status code and the content type.

๐Ÿ  Main function

Just below the constants, add the following code:

//main function
fn main() {
    //Set Database
    if let Err(_) = set_database() {
        println!("Error setting database");
        return;
    }

    //start server and print port
    let listener = TcpListener::bind(format!("0.0.0.0:8080")).unwrap();
    println!("Server listening on port 8080");

    for stream in listener.incoming() {
        match stream {
            Ok(stream) => {
                handle_client(stream);
            }
            Err(e) => {
                println!("Unable to connect: {}", e);
            }
        }
    }
}
  • set_database is a function that we will create later. It will be used to connect to the database.

  • TcpListener::bind is used to create a TCP server on port 8080.

  • listener.incoming() is used to get the incoming connections.

โ›‘๏ธ Utility functions

Now, out of the main function, add the three following utility functions:

//db setup
fn set_database() -> Result<(), PostgresError> {
    let mut client = Client::connect(DB_URL, NoTls)?;
    client.batch_execute(
        "
        CREATE TABLE IF NOT EXISTS users (
            id SERIAL PRIMARY KEY,
            name VARCHAR NOT NULL,
            email VARCHAR NOT NULL
        )
    "
    )?;
    Ok(())
}

//Get id from request URL
fn get_id(request: &str) -> &str {
    request.split("/").nth(2).unwrap_or_default().split_whitespace().next().unwrap_or_default()
}

//deserialize user from request body without id
fn get_user_request_body(request: &str) -> Result<User, serde_json::Error> {
    serde_json::from_str(request.split("\r\n\r\n").last().unwrap_or_default())
}
  • set_database connects to the database and creates the users table if it doesn't exist.

  • get_id is used to get the id from the request URL.

  • get_user_request_body is used to deserialize the user from the request body (without the id) for the Create and Update endpoints.

๐Ÿšฆ Handle client

Between the main function and the utility functions, add the following code (no worries, there will be the final code at the end of the article):

//handle requests
fn handle_client(mut stream: TcpStream) {
    let mut buffer = [0; 1024];
    let mut request = String::new();

    match stream.read(&mut buffer) {
        Ok(size) => {
            request.push_str(String::from_utf8_lossy(&buffer[..size]).as_ref());

            let (status_line, content) = match &*request {
                r if r.starts_with("POST /users") => handle_post_request(r),
                r if r.starts_with("GET /users/") => handle_get_request(r),
                r if r.starts_with("GET /users") => handle_get_all_request(r),
                r if r.starts_with("PUT /users/") => handle_put_request(r),
                r if r.starts_with("DELETE /users/") => handle_delete_request(r),
                _ => (NOT_FOUND.to_string(), "404 not found".to_string()),
            };

            stream.write_all(format!("{}{}", status_line, content).as_bytes()).unwrap();
        }
        Err(e) => eprintln!("Unable to read stream: {}", e),
    }
}

We create a buffer and then a string for the incoming requests.

Using the match statement in Rust, we can check the request and call the right function to handle it.

If we don't have a match, we send back a 404 error.

Last, we set the stream to write the response back to the client and handle any error.

๐ŸŽ›๏ธ Controllers

Now, let's create the functions that will handle the requests.

They are five functions, one for each endpoint:

  • handle_post_request for the Create endpoint

  • handle_get_request for the Read endpoint

  • handle_get_all_request for the Read All endpoint

  • handle_put_request for the Update endpoint

  • handle_delete_request for the Delete endpoint

Add the code below the handle_client function:

//handle post request
fn handle_post_request(request: &str) -> (String, String) {
    match (get_user_request_body(&request), Client::connect(DB_URL, NoTls)) {
        (Ok(user), Ok(mut client)) => {
            client
                .execute(
                    "INSERT INTO users (name, email) VALUES ($1, $2)",
                    &[&user.name, &user.email]
                )
                .unwrap();

            (OK_RESPONSE.to_string(), "User created".to_string())
        }
        _ => (INTERNAL_ERROR.to_string(), "Internal error".to_string()),
    }
}

//handle get request
fn handle_get_request(request: &str) -> (String, String) {
    match (get_id(&request).parse::<i32>(), Client::connect(DB_URL, NoTls)) {
        (Ok(id), Ok(mut client)) =>
            match client.query_one("SELECT * FROM users WHERE id = $1", &[&id]) {
                Ok(row) => {
                    let user = User {
                        id: row.get(0),
                        name: row.get(1),
                        email: row.get(2),
                    };

                    (OK_RESPONSE.to_string(), serde_json::to_string(&user).unwrap())
                }
                _ => (NOT_FOUND.to_string(), "User not found".to_string()),
            }

        _ => (INTERNAL_ERROR.to_string(), "Internal error".to_string()),
    }
}

//handle get all request
fn handle_get_all_request(_request: &str) -> (String, String) {
    match Client::connect(DB_URL, NoTls) {
        Ok(mut client) => {
            let mut users = Vec::new();

            for row in client.query("SELECT id, name, email FROM users", &[]).unwrap() {
                users.push(User {
                    id: row.get(0),
                    name: row.get(1),
                    email: row.get(2),
                });
            }

            (OK_RESPONSE.to_string(), serde_json::to_string(&users).unwrap())
        }
        _ => (INTERNAL_ERROR.to_string(), "Internal error".to_string()),
    }
}

//handle put request
fn handle_put_request(request: &str) -> (String, String) {
    match
        (
            get_id(&request).parse::<i32>(),
            get_user_request_body(&request),
            Client::connect(DB_URL, NoTls),
        )
    {
        (Ok(id), Ok(user), Ok(mut client)) => {
            client
                .execute(
                    "UPDATE users SET name = $1, email = $2 WHERE id = $3",
                    &[&user.name, &user.email, &id]
                )
                .unwrap();

            (OK_RESPONSE.to_string(), "User updated".to_string())
        }
        _ => (INTERNAL_ERROR.to_string(), "Internal error".to_string()),
    }
}

//handle delete request
fn handle_delete_request(request: &str) -> (String, String) {
    match (get_id(&request).parse::<i32>(), Client::connect(DB_URL, NoTls)) {
        (Ok(id), Ok(mut client)) => {
            let rows_affected = client.execute("DELETE FROM users WHERE id = $1", &[&id]).unwrap();

            //if rows affected is 0, user not found
            if rows_affected == 0 {
                return (NOT_FOUND.to_string(), "User not found".to_string());
            }

            (OK_RESPONSE.to_string(), "User deleted".to_string())
        }
        _ => (INTERNAL_ERROR.to_string(), "Internal error".to_string()),
    }
}
  • Some use the get_id function to get the id from the request URL.

  • The get_user_request_body function is used to get the user from the request body in JSON format and deserialize it into a User struct.

  • There is some error handling in case the request is invalid, or the database connection fails.

๐Ÿ“ Recap

Here is the complete main.rs file:

use postgres::{ Client, NoTls };
use postgres::Error as PostgresError;
use std::net::{ TcpListener, TcpStream };
use std::io::{ Read, Write };
use std::env;

#[macro_use]
extern crate serde_derive;

//Model: User struct with id, name, email
#[derive(Serialize, Deserialize)]
struct User {
    id: Option<i32>,
    name: String,
    email: String,
}

//DATABASE URL
const DB_URL: &str = env!("DATABASE_URL");

//constants
const OK_RESPONSE: &str = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n";
const NOT_FOUND: &str = "HTTP/1.1 404 NOT FOUND\r\n\r\n";
const INTERNAL_ERROR: &str = "HTTP/1.1 500 INTERNAL ERROR\r\n\r\n";

//main function
fn main() {
    //Set Database
    if let Err(_) = set_database() {
        println!("Error setting database");
        return;
    }

    //start server and print port
    let listener = TcpListener::bind(format!("0.0.0.0:8080")).unwrap();
    println!("Server listening on port 8080");

    for stream in listener.incoming() {
        match stream {
            Ok(stream) => {
                handle_client(stream);
            }
            Err(e) => {
                println!("Unable to connect: {}", e);
            }
        }
    }
}

//handle requests
fn handle_client(mut stream: TcpStream) {
    let mut buffer = [0; 1024];
    let mut request = String::new();

    match stream.read(&mut buffer) {
        Ok(size) => {
            request.push_str(String::from_utf8_lossy(&buffer[..size]).as_ref());

            let (status_line, content) = match &*request {
                r if r.starts_with("POST /users") => handle_post_request(r),
                r if r.starts_with("GET /users/") => handle_get_request(r),
                r if r.starts_with("GET /users") => handle_get_all_request(r),
                r if r.starts_with("PUT /users/") => handle_put_request(r),
                r if r.starts_with("DELETE /users/") => handle_delete_request(r),
                _ => (NOT_FOUND.to_string(), "404 not found".to_string()),
            };

            stream.write_all(format!("{}{}", status_line, content).as_bytes()).unwrap();
        }
        Err(e) => eprintln!("Unable to read stream: {}", e),
    }
}

//handle post request
fn handle_post_request(request: &str) -> (String, String) {
    match (get_user_request_body(&request), Client::connect(DB_URL, NoTls)) {
        (Ok(user), Ok(mut client)) => {
            client
                .execute(
                    "INSERT INTO users (name, email) VALUES ($1, $2)",
                    &[&user.name, &user.email]
                )
                .unwrap();

            (OK_RESPONSE.to_string(), "User created".to_string())
        }
        _ => (INTERNAL_ERROR.to_string(), "Internal error".to_string()),
    }
}

//handle get request
fn handle_get_request(request: &str) -> (String, String) {
    match (get_id(&request).parse::<i32>(), Client::connect(DB_URL, NoTls)) {
        (Ok(id), Ok(mut client)) =>
            match client.query_one("SELECT * FROM users WHERE id = $1", &[&id]) {
                Ok(row) => {
                    let user = User {
                        id: row.get(0),
                        name: row.get(1),
                        email: row.get(2),
                    };

                    (OK_RESPONSE.to_string(), serde_json::to_string(&user).unwrap())
                }
                _ => (NOT_FOUND.to_string(), "User not found".to_string()),
            }

        _ => (INTERNAL_ERROR.to_string(), "Internal error".to_string()),
    }
}

//handle get all request
fn handle_get_all_request(_request: &str) -> (String, String) {
    match Client::connect(DB_URL, NoTls) {
        Ok(mut client) => {
            let mut users = Vec::new();

            for row in client.query("SELECT id, name, email FROM users", &[]).unwrap() {
                users.push(User {
                    id: row.get(0),
                    name: row.get(1),
                    email: row.get(2),
                });
            }

            (OK_RESPONSE.to_string(), serde_json::to_string(&users).unwrap())
        }
        _ => (INTERNAL_ERROR.to_string(), "Internal error".to_string()),
    }
}

//handle put request
fn handle_put_request(request: &str) -> (String, String) {
    match
        (
            get_id(&request).parse::<i32>(),
            get_user_request_body(&request),
            Client::connect(DB_URL, NoTls),
        )
    {
        (Ok(id), Ok(user), Ok(mut client)) => {
            client
                .execute(
                    "UPDATE users SET name = $1, email = $2 WHERE id = $3",
                    &[&user.name, &user.email, &id]
                )
                .unwrap();

            (OK_RESPONSE.to_string(), "User updated".to_string())
        }
        _ => (INTERNAL_ERROR.to_string(), "Internal error".to_string()),
    }
}

//handle delete request
fn handle_delete_request(request: &str) -> (String, String) {
    match (get_id(&request).parse::<i32>(), Client::connect(DB_URL, NoTls)) {
        (Ok(id), Ok(mut client)) => {
            let rows_affected = client.execute("DELETE FROM users WHERE id = $1", &[&id]).unwrap();

            //if rows affected is 0, user not found
            if rows_affected == 0 {
                return (NOT_FOUND.to_string(), "User not found".to_string());
            }

            (OK_RESPONSE.to_string(), "User deleted".to_string())
        }
        _ => (INTERNAL_ERROR.to_string(), "Internal error".to_string()),
    }
}

//db setup
fn set_database() -> Result<(), PostgresError> {
    let mut client = Client::connect(DB_URL, NoTls)?;
    client.batch_execute(
        "
        CREATE TABLE IF NOT EXISTS users (
            id SERIAL PRIMARY KEY,
            name VARCHAR NOT NULL,
            email VARCHAR NOT NULL
        )
    "
    )?;
    Ok(())
}

//Get id from request URL
fn get_id(request: &str) -> &str {
    request.split("/").nth(2).unwrap_or_default().split_whitespace().next().unwrap_or_default()
}

//deserialize user from request body without id
fn get_user_request_body(request: &str) -> Result<User, serde_json::Error> {
    serde_json::from_str(request.split("\r\n\r\n").last().unwrap_or_default())
}

We are done with the app code. Now it's the turn of Docker.

๐Ÿณ Docker

We will build the Rust app directly inside the image. We will use an official Rust image as the base image. We will also use the official Postgres image as a base image for the database.

We will create three files:

  • .dockerignore: to ignore files and folders that we don't want to copy in the image filesystem

  • Dockerfile: to build the Rust image

  • docker-compose.yml: to run the Rust and Postgres services (containers)

You can create them using the terminal or your code editor.

touch .dockerignore Dockerfile docker-compose.yml

๐Ÿšซ .dockerignore

Open the .dockerignore file and add the following:

**/target

This is to avoid copying the target folder in the image filesystem.

๐Ÿ‹ Dockerfile

We will use a multi-stage build. We will have:

  • a build stage: to build the Rust app

  • a production stage: to run the Rust app

Open the Dockerfile and add the following (explanations in comments):

# Build stage
FROM rust:1.69-buster as builder

WORKDIR /app

# Accept the build argument
ARG DATABASE_URL

# Make sure to use the ARG in ENV
ENV DATABASE_URL=$DATABASE_URL

# Copy the source code
COPY . .

# Build the application
RUN cargo build --release


# Production stage
FROM debian:buster-slim

WORKDIR /usr/local/bin

COPY --from=builder /app/target/release/rust-crud-api .

CMD ["./rust-crud-api"]

Please notice that we are using rust-crud-api as the executable's name. This is the name of the project folder. If you have a different name, please change it.

๐Ÿ™ docker-compose.yml

Populate the docker-compose.yml file with the following:

version: '3.9'

services:
  rustapp:
    container_name: rustapp
    image: francescoxx/rustapp:1.0.0
    build:
      context: .
      dockerfile: Dockerfile
      args:
        DATABASE_URL: postgres://postgres:postgres@db:5432/postgres
    ports:
      - '8080:8080'
    depends_on:
      - db

  db:
    container_name: db
    image: 'postgres:12'
    ports:
      - '5432:5432'
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
      - POSTGRES_DB=postgres
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata: {}
  • We have two services, rustapp and db. The rustapp service is built using the Dockerfile we created before. The db service uses the official Postgres image. We are using the depends_on property to ensure the db service is started before the rustapp service.

  • Notice that the DATABASE_URL build argument is set to postgres://postgres:postgres@db:5432/postgres. db is the name of the service (and the container_name) of the Postgres container so that it will be resolved to the container IP address.

  • We use the arg property to pass the DATABASE_URL build argument to the Dockerfile.

  • We also use a named volume, pg_data, to persist the database data.

Now it's time to build the image and run the containers.

๐Ÿ—๏ธ Build the image and run the containers

We need just three more steps:

  • run the postgres container

  • build the Rust app image

  • run the Rust app container

๐Ÿ˜ Run the Postgres container

First, run the postgres container:

docker-compose up -d db

This will pull (download) the image from DockerHub and run it on our machine.

To see the logs, you can type

docker-compose logs db

If you have something like this, it means that the database is up and running in the container (the last line of the logs should say: "database system is ready to accept connections")

Rust project structure

๐Ÿ—๏ธ Build the Rust app image

It's time to build the Rust app image. We will use the docker-compose build command. This will build the image using the Dockerfile we created before.

(Note: we might type docker compose up, but by doing that, we would skip understanding what's happening. In a nutshell, when we type docker compose up, Docker builds the images if needed and then runs the containers).

docker compose build

This takes time because we are building the Rust app inside the image.

After ~150 seconds (!), we should have the image built.

Rust project structure

๐Ÿ‘Ÿ Run the Rust Container

Now we can run the Rust container:

docker compose up rustapp

You can check both containers by opening another terminal and typing:

docker ps -a

Lastly, you can check the postgres database by typing:

docker exec -it db psql -U postgres
\dt
select * from users;

Here is a screenshot of the output:

Rust project structure

It's now time to test our application.

๐Ÿงช Test the application

To test the application, we will use Postman. You can download it from here.

๐Ÿ“ Test the db connection

Since we don't have a dedicated endpoint to test the db connection, we will make a GET request to http://localhost:8080/users

The output should be []. This is correct, as the database is empty.

GET request to http://localhost:8080/users

๐Ÿ“ Create a new user

To create a new user, make a POST request to http://localhost:8080/users with the following body:

โš ๏ธ Add the header "Content-Type: application/json" in the request

{
    "name": "aaa",
    "email": "aaa@mail"
}

POST request to http://localhost:8080/users

Create two more users with the following bodies at the same endpoint making a POST request to http://localhost:8080/users

{
    "name": "bbb",
    "email": "bbb@mail"
}
{
    "name": "ccc",
    "email": "ccc@mail"
}

๐Ÿ“ Get all users

To get all the users, make a GET request to http://localhost:8080/users

GET request to http://localhost:8080/users

๐Ÿ“ Get a single user (with error handling)

To get a single user, we can specify the id in the URL.

For example, to get the user with id 1, we can make a GET request to http://localhost:8080/users/1

GET request to http://localhost:8080/users/1

Notice that if we try to get a user with an id that doesn't exist, we get an error.

Make a GET request to http://localhost:8080/users/10

GET request to http://localhost:8080/users/10

And if we try to get a user py using a string instead of an integer, we also get an error.

Make a GET request to http://localhost:8080/users/aaa

GET request to http://localhost:8080/users/aaa

๐Ÿ“ Update a user

We must pass an id in the URL and a body with the new data to update an existing user.

For example, make a PUT request to http://localhost:8080/users/2 with the following body:

{
    "name": "NEW",
    "email": "NEW@mail"
}

PUT request to http://localhost:8080/users/1

๐Ÿ“ Delete a user

Finally, to delete a user, we need to pass the id in the URL.

For example, make a DELETE request to http://localhost:8080/users/3

DELETE request to http://localhost:8080/users/3

๐Ÿข Test with TablePlus

You can also test the application with TablePlus.

Create a new Postgres connection with the following credentials:

  • Host: localhost

  • Port: 5432

  • User: postgres

  • Password: postgres

  • Database: postgres

And click the connect button at the bottom right.

TablePlus

This will open a new window with the database.

You can check the users table and see that the data is there.

TablePlus

Done.

๐Ÿ Conclusion

We made it!

We created a REST API with Rust, Serde, Postgres and Docker.

If you prefer a video version:

All the code is available in the GitHub repository (link in the video description): youtu.be/vhNoiBOuW94

That's all.

If you have any questions, drop a comment below.

Francesco

Did you find this article valuable?

Support Francesco Ciulla by becoming a sponsor. Any amount is appreciated!