In this article we will implement a backend api server for a contacts application that uses Postgres for persistent data storage.
NOTE: I am working on Linux and some of these instructions might not work for Other OSs.
We will use Actix - a powerful and pragmatic web framework, diesel - 'a safe & extensible rust ORM' and Postgres for persistent data storage.
Table of contents
Introduction
This article assumes that you are familiar with Rust.
If not fret not, If you have build a server using another framework like express before, it will be easy to identify patterns between the two. P.S - set up your rust dev environment by following this guide
Scaffold and new Rust project using cargo by running, cargo new contacts-backend
on the terminal.
Some convenient tools, that will make our development easier that you should install include:
- cargo watch - like nodemon. auto-reload the server when we make changes
- cargo edit - simplify the addition of dependencies.
- cargo clippy - a rust linter.
cargo install cargo-edit cargo-watch
Separating our code accordingly, here is an overview of how our file structure will look after we are done.
No need to get started creating the files yet, we will do so as we move along.
contacts-backend
├── src
│ ├── data
│ │ ├── mod.rs
│ │ ├── user_repository.rs
│ │ └── contact_repository.rs
│ ├── handlers
│ │ ├── mod.rs
│ │ ├── user_handler.rs
│ │ └── contact_handler.rs
│ ├── models
│ │ ├── mod.rs
│ │ ├── user.rs
│ │ └── contact.rs
│ ├── db.rs
│ ├── schema.rs
│ ├── main.rs
│ └── routes.rs
├── .env
├── diesel.toml
├── Cargo.toml
└── Cargo.lock
└── diesel.toml
Diesel and Postgres
For persistent data storage we will use Postgres DB, locally. I have some notes on getting started with it. If you prefer to use an online hosted Database as a Service like supabase, that is okay too. Whatever floats your boat.
Our simple database will host two relations, contacts and users. This is how our schema will look like.
Table: user_profile
user_id (PK): auto-incremented integer
username: String
password: String
email: String
first_name: String
last_name: String
Table: contact
contact_id (Primary Key): (auto-incremented integer)
user_id (Foreign Key): linking the contact to its owner in the Users table
email: string
Phone: string
city: string
country: string
Setting up Our Project with Diesel CLI
Diesel is a Rust ORM. It also comes with a convenient tool to enable us manage our database schemas. diesel_cli.
Add the diesel create to our project, with the postgres
features flag. We will need dotenvy
crate to enable use work with our .env
secrets.
cargo add diesel --features=postgres
cargo add dotenvy
Install the diesel-cli to our system.
cargo install diesel_cli --no-default-features --features postgres
The --features
flag tell diesel_cli that we will working with postgres as our primary DB.
NOTE: Make sure you have the client libraries for postgres, mysql and sqlite installed
sudo apt install libpq-dev libmysqlclient-dev libsqlite3-dev
Or you might come across an error similar to note: ld: library not found for ...
.
Let's get started by creating the database url, in our .env
file. Replace username
and password
with your username
and password
. If you don't know about postgres role, check out my postgres notes I.
echo DATABASE_URL=postgres://username:password@localhost/contacts_db > .env
NOTE:
Make sure that you don't have any trailing white space for the DATABASE_URL
variable as dotenvy will panic with this cryptic message environment variable not found
.
❌ 🙅♂️ |
---|
Don't forget to add .env, to the .gitignore file |
The setup
command below from diesel, will create our database if we haven't already, an empty migrations
directory and a diesel.toml
file, that configures the behaviour of diesel cli.
diesel setup
Next, we will create the migrations files for our users relation.
diesel migration generate create_users
Output
Creating migrations/2023-07-08-133307_create_users/up.sql
Creating migrations/2023-07-08-133307_create_users/down.sql
We will write the SQL for the migration and for reverting the migration. Database migrations become very important whe you are working with a team and decide to change your schema. Read more about why migrations are important in this Prisma guide.
This is the migration to create the relation,
CREATE TABLE user_profile (
user_id integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
username VARCHAR(255) NOT NULL,
password VARCHAR NOT NULL,
--because we will use sha-512 hashing algorithm
email VARCHAR NOT NULL,
first_name VARCHAR,
last_name VARCHAR
);
While this SQL reverts the database change
DROP TABLE user_profile
To execute the statements, use
diesel migration run
Should you have a reason to revert the state of the database run
diesel migration redo
Next the migration for the contacts relation. Generate a new migration directory diesel migration generate create_contacts
for the relation and paste the sql statements for its creation and deletion.
CREATE TABLE contact (
contact_id integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
user_id integer REFERENCES user_profile(user_id),
email VARCHAR NOT NULL,
phone VARCHAR(30),
city VARCHAR(255)
country VARCHAR(255)
);
DROP TABLE contact
Finally, do the thing, diesel migration run
.
NOTE: |
---|
Trailing commas in SQL are not allowed. Also user is a reserved word in postgres. |
Using Diesel ORM in our Project
We can now cd into the src
directory and write the code for integrating the db. Create db.rs
which we will use to establish a connection to our database.
touch db.rs
db.rs code.
use diesel::pg::PgConnection;
use diesel::prelude::*;
use dotenvy::dotenv;
use std::env;
pub fn establish_db_connection() -> PgConnection {
dotenv().ok();
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
PgConnection::establish(&database_url).expect(&format!("Error connecting to {}", database_url))
}
Diesel cli also, generates a schema.rs
file when we ran diesel migration run
. It is a high level abstraction of our database, enabling us to safely interact with it.
Declare both files as a modules in main.rs
.
mod db;
mod schema;
mod models;
fn main() {
println!("Hello, world!");
}
Let us create our models directory, which will act as a representation of the individual entities in our db. In this directory, we will also create three new files.
mod.rs
- enable us to share the code as a module to the main file.user.rs
- represent the user relation.contact.rs
- represent the contact relation.
mkdir models
cd models
touch mod.rs user.rs contact.rs
Declare the two modules in mod.rs
as public to make them visible in main, i.e
pub mod contact;
pub mod user;
Lets write code for the user profile
use diesel::prelude::*;
#[derive(AsChangeset, Identifiable, Queryable, Selectable)]
#[diesel(primary_key(user_id))]
#[diesel(table_name = crate::schema::user_profile)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct UserProfile {
user_id: i32,
username: String,
password: String,
email: String,
first_name: Option<String>,
last_name: Option<String>,
}
Let's go over the attributes one by one,
-
#[derive(AsChangeset, Identifiable, Queryable, Selectable)]
Queryable Will generate the code to load a UserProfile from a sql query. It assumes the order of your struct matches the columns of your relation.
Selectable generates code to construct a matching
SELECT
clause based on the schema which we define using the#[diesel(table_name = crate::schema::user_profile)]
attribute. In ouruser_profile
model above, the raw sql statement would beselect * from user_profile
.Identifiable provides the necessary functionality to identify and retrieve records from a database. The raw sql query might be
select * from user_profile where id = 1
AsChangeset also enables use to update a record by passing a
&UserProfile
to.set
. tl;dr - Provides the SET clause of the UPDATE statement. -
#[diesel(primary_key(user_id))]
- We use this attribute to tell diesel what our primary key is. -
#[diesel(table_name = crate::schema::user_profile)]
- as explained above is what makes the Selectable trait work. -
#[diesel(check_for_backend(diesel::pg::Pg))] add compile time checks for our types, improving on the error message we receive.
Now for the contact
model
use diesel::prelude::*;
use crate::models::user::UserProfile;
#[derive(AsChangeset, Identifiable, Queryable, Selectable, Associations)]
#[diesel(belongs_to(UserProfile, foreign_key = user_id))]
#[diesel(primary_key(contact_id))]
#[diesel(table_name = crate::schema::contact)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct Contact {
contact_id: i32,
user_id: Option<i32>,
email: String,
phone: Option<String>,
city: Option<String>,
country: Option<String>,
}
Two important additions are to be noted here.
-
#[derive(AsChangeset, Identifiable, Queryable, Selectable, Associations)]
. The use of Associations trait, which enables us represent a one-many relationship between ouruser_profile
andcontact
relation. -
#[diesel(belongs_to(UserProfile, foreign_key = user_id))]
- Here, we tell diesel the name of our foreign key.
Implementing our Crud Operations
We will get started by creating a data
directory in the src
directory, and in it create the user_repository
and contact_repository
files.
The two files will handle all our database logic for reads, writes, updates and deletions of records in our database.
mkdir data
cd data
touch mod.rs user_repository.rs contact_repository.rs
Update main.rs to include our module.
mod db;
mod data;
mod models;
mod schema;
...
Update the src/data/mod.rs
to recognize our user_repository.rs
and contact_repository.rs
mods.
mod contact_repository;
mod user_repository;
We will first work on inserting a user to the users relation. Let's head over back to our users model - src/models/users.rs
We have two approaches to inserting data using diesel.
The first is by using tuples. The following Rust, diesel code
use crate::schema::user_profile::dsl::*;
use diesel::*;
let conn = &mut establish_db_connection();
insert_into(user_profile)
.values((
username.eq("shadow"),
password.eq("176c1e..50c7e"),
email.eq("cidkage@shadowgarden.com"),
first_name.eq("Cid"),
last_name.eq("Kagenou"),
))
.execute(conn)
.unwrap();
Would be equivalent to the following SQL statement.
INSERT INTO user_profile (username, password, email, first_name, last_name)
VALUES ('shadow', '176c1e..50c7e', 'cidkage@shadowgarden.com', 'Cid', 'Kagenou');
This would still work, but it's cumbersome, especially if we had large relations and had to deserialize data everytime we received it.
The second approach is using the Insertable
trait. Once derived, it allows us to map our relations to a struct defined in our code. This is the approach we will use.
In our case, let us create a new struct NewUserProfile
in the users.rs
module, that will be responsible to the creation of new users.
#[derive(Insertable)]
#[diesel(table_name = crate::schema::user_profile)]
pub struct NewUserProfile {
pub username: String,
pub password: String,
pub email: String,
pub first_name: Option<String>,
pub last_name: Option<String>,
}
You might be wondering why we haven't included user_id
in our new struct, and that is because of this statement user_id integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
we wrote when creating our relations. It autoincrements the value of user_id
each time a new record is inserted.
We are now ready to write code to handle our CRUD operations.
Populate your src/data/user_repository
with the following,
use crate::models::user::{NewUserProfile, UserProfile};
use crate::schema::user_profile;
use diesel::pg::PgConnection;
use diesel::prelude::*;
pub fn create_user(conn: &mut PgConnection, new_user: NewUserProfile) -> UserProfile {
diesel::insert_into(user_profile::table)
.values(&new_user)
.returning(UserProfile::as_returning())
.get_result(conn)
.expect("error inserting new record into database")
}
We have the create_user
function that takes a conn
parameter to our database connection and new_user
which will contain the new values we want to insert into our database.
Some points to note about the functions:
-
Our database supports the
RETURNING
clause and that is why we are able to use theget_result
method. -
This clause is what allows us to get back rows of all the inserted rows, i.e
-> UserProfile
.We could probably do better by returning a results but we'll save that for next time.
We can do the same for our contacts. Edit your src/models/contact.rs
to include the data model for a new contact in initialized with theNewContact
struct.
#[derive(Insertable, Debug)]
#[diesel(table_name = crate::schema::contact)]
pub struct NewContact {
user_id: Option<i32>,
email: String,
phone: Option<String>,
city: Option<String>,
country: Option<String>,
}
And finally, populate your src/data/contact_repository.rs
with the function to create a new contact record in our contacts relation.
use crate::models::contact::{Contact, NewContact};
use crate::schema::contact;
use diesel::pg::PgConnection;
use diesel::prelude::*;
pub fn create_contact(conn: &mut PgConnection, new_contact: NewContact) -> Contact {
diesel::insert_into(contact::table)
.values(&new_contact)
.returning(Contact::as_returning())
.get_result(conn)
.expect("error inserting new contact record into database")
}
We are ready to write test for our methods, but before moving forward, let's format our code and run the Rust linter.
cargo fmt --all
cargo clippy
As show in the screenshot below, cargo clippy
generates a list of warning and even suggests changes we can make to improve our codebase.
We can ignore the first two about dead_code
as we have yet to make us of our create_user
and create_contact
functions yet.
Let us tackle the third warning. The warning here is that expect
will still execute the next line, not matter the Result
we obtain in this line. To fix the warning, clippy suggests we use the unwrap_or_else
method from Result
, which takes a closure that will be executed should our statement return the Err
variant of Result
.
Since, there isn't much we can do without a database url, the right call IMO should be to panic!
. Lets head over to the src/db.rs
file and change our establish_db_connection
function to,
pub fn establish_db_connection() -> PgConnection {
dotenv().ok();
let database_url = env::var("DATABASE_URL").unwrap_or_else(|err| {
panic!("check your DATABASE_URL env configuration: {err}");
});
PgConnection::establish(&database_url).unwrap_or_else(|err| {
panic!("Error connecting to the database: {err}");
})
}
Should you be writing production code, you should be handling these Err
s more gracefully. Diesel provides a non-exhaustive ConnectionError
enum that you should utilize.
Tests for our database
Let's write some simple unit tests, to test the validity of our diesel code. We will later use this for our actix tests too.
mkdir tests
cd tests
touch mod.rs db_tests.rs
Declare the tests module in main.rs
.
mod db;
mod data;
mod models;
mod schema;
mod tests;
...
And in src/tests/mod.rs
, declare the db_tests.rs
module
mod db_tests;
We will use the test_transaction
method which starts a transaction and is rolled back at the end of the test. We will test reads, writes to the user_profile
relation.
Populate your test code like this
#[cfg(test)]
mod tests {
use crate::data::user_repository;
use crate::db::establish_db_connection;
use crate::models::user::{NewUserProfile, UserProfile};
use crate::schema::user_profile::dsl::*;
use diesel::prelude::*;
use diesel::result::Error;
// test writing and reading to the user_profile relation
fn read_user_profile_records(conn: &mut PgConnection) -> QueryResult<Vec<UserProfile>> {
user_profile
.select(UserProfile::as_select())
.load::<UserProfile>(conn)
}
#[test]
fn test_user_profile_write() {
let connection = &mut establish_db_connection();
let new_user = NewUserProfile {
username: "shadow".to_string(),
password: "12312".to_string(),
email: "fake@email.com".to_string(),
first_name: Some("shadow".to_string()),
last_name: None,
};
connection.test_transaction::<_, Error, _>(|conn| {
let created_user = user_repository::create_user(conn, new_user.clone());
assert_eq!(created_user.email, new_user.email);
println!("user id {:?}", created_user.user_id);
let res = read_user_profile_records(conn).unwrap();
assert_eq!(res.len(), 1);
Ok(())
});
}
}
You might be asking why we are using a mutable reference for the connection variable, and the answer to that would be because diesel 2.0 changed their API.
NOTE: The line use crate::schema::user_profile::dsl::*;
imports a bunch of code so that we don't have to write user_profile::table
. The diesel guides recommend that you import it inside your function to avoid polluting your namespace, but since we are only working with the relation, I think it's safe to leave it as is.
As before, we will need to first create a function to update our relation row in the src/data/contact_repository.rs
.
pub fn update_user_profile_by_id(
conn: &mut PgConnection,
updated_user_profile: UserProfile,
) -> UserProfile {
diesel::update(user_profile::table)
.set(updated_user_profile)
.get_result(conn)
.expect("error updating specified recor")
}
Passing in our UserProfile
itself is how we are selecting the user_profile to update. It is equivalent to doing, update(user_profile.find(user_profile.user_id))
or update(user_profile.filter(user_id.eq(user_profile.user_id)))
We finally add the function for our delete
operation.
pub fn delete_user_profile_by_id(user_id: &i32, conn: &mut PgConnection) {
use crate::schema::user_profile::dsl::*;
diesel::delete(user_profile.filter(user_id.eq(user_id)))
.execute(conn)
.expect("error deleting the user record");
}
Notice the use of the use crate::schema::user_profile::dsl::*;
which enables us avoid having to write user_profile::table
each time we want to run a query.
In our src/db_tests.rs
file, we are going to create a function that abstracts the creation of a new user for us and use it instead of declaring a new_user
with struct literals in each test.
//generates a dummy user for us
fn generate_dummy_user_profile() -> NewUserProfile {
NewUserProfile {
username: "shadow".to_string(),
password: "12312".to_string(),
email: "fake@email.com".to_string(),
first_name: Some("shadow".to_string()),
last_name: None,
}
}
We want to update the username
, first_name
and last_name
fields. Like JavaScript rust also has the spread operator which we will make use of for the update test.
#[test]
fn test_user_profile_update_and_delete() {
let connection = &mut establish_db_connection();
let new_user = generate_dummy_user_profile();
connection.test_transaction::<_, Error, _>(|conn| {
let created_user = user_repository::create_user(conn, new_user.clone());
let _username = String::from("sasuga_shadow_sama");
let _first_name = Some(String::from("Cid"));
let _last_name = Some(String::from("Kagenou"));
let updated_user = UserProfile {
username: _username.clone(),
first_name: _first_name.clone(),
last_name: _last_name.clone(),
..created_user
};
let updated_user = user_repository::update_user_profile_by_id(conn, updated_user);
//assert whether our values have been updated as expected
assert_eq!(updated_user.username, _username);
assert_eq!(updated_user.first_name, _first_name);
assert_eq!(updated_user.last_name, _last_name);
let res = read_user_profile_records(conn)?;
assert_eq!(res.len(), 1);
//deleting our user
delete_user_profile_by_id(&updated_user.user_id, conn);
let users = read_user_profile_records(conn)?;
assert!(users.is_empty());
Ok(())
});
}
In this test we update a user_profile, delete it and assert that the array of users is empty at the end of the test.
You might be wondering why the values we are updating are declared with an underscore to them i.e let _username = ...
. This is because the generated src/schema.rs
code is a rust table!
module, with each of the fields/columns being generated as structs that implement the Expression
trait. More on this in the diesel guides.
The code up to this point can be found in this branch.
In the next part we will set up everything with actix and get our api ready.