Production-ready microservice in Rust: 7. Basic CRUD operations
Posted 2023-06-25 13:32:00 by sanyi ‐ 7 min read
Basic CRUD operations with SeaORM
Last time I connected to a PostgreSQL database and run a migration to create a few tables. Now I will create the Rust entity structs and insert some data.
First create a new package for our entities. Add the entity
package to the main Cargo.toml
:
[workspace]
members = [
"shelter_main",
"migration",
"entity"
]
Then create the entity
subdirectory and add a Cargo.toml
there too:
[package]
name = "entity"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
name = "entity"
path = "src/lib.rs"
[dependencies]
serde = { version = "1", features = ["derive"] }
sea-orm = { version = "0.11" }
chrono = { version = "0.4" }
We will use serde
for entity data serialization and deserialization, sea-orm
for the entities
obviously and the NaiveDate
type from chrono
.
Create the entity/src/lib.rs
only to include our dog and user submodules:
pub mod dog;
pub mod user;
Now create the entity/src/user.rs
file to implement our User entity.
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "user")]
pub struct Model {
#[sea_orm(primary_key)]
#[serde(skip_deserializing)]
pub id: i32,
pub username: String,
pub password: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
The Model
struct defines our entity structure. The sea_orm(table_name = "user")
declaration
provides the name of the SQL database table storing our entities. The sea_orm(primary_key)
identifies the
primary key of the table. The serde(skip_deserializing)
is not strictly required, I only use it
to simplify the loading of data from JSON structures.
The Relation
enum could list our entity relations but we have no relations yet.
The ActiveModel
struct will provide create-update-delete operations for our model.
The entity/src/dog.rs
will implement our Dog entity, it's quite similar to the user.rs
:
use chrono::NaiveDate;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "dog")]
pub struct Model {
#[sea_orm(primary_key)]
#[serde(skip_deserializing)]
pub id: i32,
pub name: String,
#[sea_orm(column_type = "Text")]
pub description: String,
pub date_of_birth: NaiveDate,
pub date_of_vaccination: Option<NaiveDate>,
pub chip_number: String,
pub gender: String,
pub is_sterilized: bool,
pub breed: String,
pub size: String,
pub weight: Option<i32>,
pub hair: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
There are two new things here: the column_type = "Text"
declaration for the description
field
(to indicate that it's not a varchar but a text field) and the two NaiveDate
fields: date_of_birth
and date_of_vaccination
. The NaiveDate
type is naive because it does not store timezone information.
The date_of_vaccination
and weight
fields are optional, because they are nullable in the SQL schema.
To test our new entities, let's build a createadmin
command to add a default admin
user to the
user
table.
First extend our shelter_main/Cargo.toml
to include our new entity
package:
[dependencies]
...
migration = { path = "../migration" }
entity = { path = "../entity" }
Include the new createadmin.rs
in shelter_main/src/commands/mod.rs
:
mod createadmin;
mod hello;
mod migrate;
mod serve;
use crate::settings::Settings;
use clap::{ArgMatches, Command};
pub fn configure(command: Command) -> Command {
command
.subcommand(hello::configure())
.subcommand(serve::configure())
.subcommand(migrate::configure())
.subcommand(createadmin::configure())
}
pub fn handle(matches: &ArgMatches, settings: &Settings) -> anyhow::Result<()> {
hello::handle(matches, settings)?;
serve::handle(matches, settings)?;
migrate::handle(matches, settings)?;
createadmin::handle(matches, settings)?;
Ok(())
}
Finally add shelter_main/src/commands/createadmin.rs
:
use crate::settings::Settings;
use anyhow::anyhow;
use clap::{Arg, ArgMatches, Command};
use serde_json::json;
pub fn configure() -> Command {
Command::new("createadmin")
.about("Create the default admin user")
.arg(
Arg::new("password")
.short('p')
.long("password")
.value_name("PASSWORD")
.help("Password for admin user")
.default_value("Pa$$wd123"),
)
}
pub fn handle(matches: &ArgMatches, settings: &Settings) -> anyhow::Result<()> {
if let Some(matches) = matches.subcommand_matches("createadmin") {
let password = matches.get_one::<String>("password").unwrap();
// TBD
Ok(())
}
This is highly similar to the existing serve.rs
but it takes a string password argument
instead of the integer port number.
To use SeaORM we have to initialize a tokio runtime in the handle method, just like in the migrate command and create a new database connection:
pub fn handle(matches: &ArgMatches, settings: &Settings) -> anyhow::Result<()> {
if let Some(matches) = matches.subcommand_matches("createadmin") {
let password = matches.get_one::<String>("password").unwrap();
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(async move {
let db_url = settings.database.url.clone().unwrap_or("".to_string());
let conn = Database::connect(db_url)
.await
.expect("Database connection failed");
// TBD
Ok::<(), anyhow::Error>(())
})?;
}
Ok(())
}
To start using SeaORM add these use
declarations to the top of the file:
use sea_orm::ColumnTrait;
use sea_orm::QueryFilter;
use sea_orm::{ActiveModelTrait, Database, EntityTrait};
use serde_json::json;
Now just after the database connection we can check whether the admin user does exist already:
let admins: Vec<entity::user::Model> = entity::user::Entity::find()
.filter(entity::user::Column::Username.eq("admin"))
.all(&conn)
.await?;
if !admins.is_empty() {
println!("Admin user already exists");
return Ok(());
}
This section uses our user
entity, tries to list all records where username
is equal to admin
.
We will print an error message when the user already exists.
Let's go on, create a new admin user:
let admin_model = entity::user::ActiveModel::from_json(json!({
"username": "admin",
"password": password,
}))?;
if let Ok(_admin) = admin_model.save(&conn).await {
println!("Admin user created");
} else {
println!("Failed to create admin user");
}
Here we create a new ActiveModel
instance and fill it with data from a json
string.
We could set all the fields manually but this method is a bit simpler.
The save
method is a syntactic sugar: it will execute an insert when the primary key
is not defined and an update when the primary key is already known.
The save
method returns a Result
: either an Ok() with the new model instance or
and Err() with the error.
Well, saving a password in clear text is not a best practice, so we will encrypt it.
You have to add some crates to shelter_main/Cargo.toml
:
[dependencies]
...
password-hash = "0.5"
argon2 = "0.5"
and some use statements to createadmin.rs
:
use argon2::Argon2;
use password_hash::rand_core::OsRng;
use password_hash::{PasswordHasher, SaltString};
Password hashing is quite simple:
fn encrypt_password(password: &str) -> anyhow::Result<String> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
if let Ok(hash) = argon2.hash_password(password.as_bytes(), &salt) {
Ok(hash.to_string())
} else {
Err(anyhow!("Failed to hash password"))
}
}
We generate a random salt and run the hash_password
method of the Argon2
implementation on the password.
We have to convert the string into a byte sequence first, password.as_bytes()
does this.
Now we can use the hashed password instead of the plain text one:
let encrypted_password = encrypt_password(password)?;
let admin_model = entity::user::ActiveModel::from_json(json!({
"username": "admin",
"password": encrypted_password,
}))?;
Finally we can build and test our project:
$ cargo build
$ docker-compose up -d
$ export SHELTER__DATABASE__URL="postgresql://postgres:[email protected]/shelter"
$ ./target/debug/shelter_main createadmin --help
Create the default admin user
Usage: shelter_main createadmin [OPTIONS]
Options:
-p, --password <PASSWORD> Password for admin user [default: Pa$$wd123]
-h, --help Print help
$ ./target/debug/shelter_main createadmin -p Passwd123
Admin user created
First time it will print Admin user created
but all subsequent executions will print Admin user already exists
.
If you connect to the database directly you can check that the admin user was created with an encrypted password:
shelter=# select * from "user";
id | username | password
----+----------+---------------------------------------------------------------------------------------------------
1 | admin | $argon2id$v=19$m=19456,t=2,p=1$ZcgMWV38klCYd0s536lV0w$eNqbKjDMnrx/wcf3roGwKttxKBFbcG1vN++98WkUx3I
(1 row)
You can find the sample code on GitHub