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): https://youtu.be/vhNoiBOuW94
๐ Intro
Here is a schema of the architecture of the application we are going to create:
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:
- Check the prerequisites
- Project creation and dependency installation
- Code the application
- Run the Postgres database with Docker
- Build and run the application with Docker Compose
- 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:
We are now ready to code the application.
๐ฉโ๐ป Code the application
We will go step by step:
- Import the dependencies.
- Create the model (a user with Id, name, and email) and add constants.
- Main function: database connection and TCP server.
- Utility functions: set_database, get_id, get_user_request_body.
- Create the routes in a function (endpoints).
- Create utility functions.
- 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 headerContent-Type: application/json
to the response. -
OK_RESPONSE,
NOT_FOUND
, andINTERNAL_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 theusers
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 theCreate
andUpdate
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 theCreate
endpoint -
handle_get_request
for theRead
endpoint -
handle_get_all_request
for theRead All
endpoint -
handle_put_request
for theUpdate
endpoint -
handle_delete_request
for theDelete
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 aUser
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
anddb
. Therustapp
service is built using the Dockerfile we created before. Thedb
service uses the official Postgres image. We are using thedepends_on
property to ensure thedb
service is started before therustapp
service.Notice that the
DATABASE_URL
build argument is set topostgres://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 theDATABASE_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")
๐๏ธ 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.
๐ 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:
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.
๐ 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"
}
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 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
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
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
๐ 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"
}
๐ 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
๐ข 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.
This will open a new window with the database.
You can check the users
table and see that the data is there.
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): https://youtu.be/vhNoiBOuW94
That's all.
If you have any questions, drop a comment below.