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

Next article ยป

Tags:
rust microservice dog-shelter