This is a condensed walkthrough of building a skeleton Rust web app using Rocket, Diesel and PostgreSQL heavily based on a more thorough series of blog posts called "Making a simple blog with Rust" and updated to incorporate the recent API changes as well as using a different database schema and a few extra tools.
For a more detailed explanation of each section, please refer to the original blog:
Making a simple blog with Rust - Part I
Making a simple blog with Rust - Part II
I am using Rust version 1.30.0-nightly (6e0f1cc15 2018-09-05).
The code for this project is available on my GitHub.
First things first
I wanted to make a very basic skeleton for a
prelaunchr type web
app with a stripped-down schema called voskhod
.
First, I started a new Rust project:
cargo new voskhod
This generated a new binary (application) project as opposed to a library
one which you can get by adding a --lib
flag to the above command. The difference
is in the template used for the directory structure.
This is my initial directory structure:
. ├── Cargo.toml └── src └── main.rs
I've been using cargo-edit
to
streamline adding dependencies. It provides a cargo add
subcommand which, when no version
is specified, will try to query the latest version's number from crates.io
and then add that package to Cargo.toml
.
Here's the command I used to add the dependencies needed:
cargo add rocket rocket_codegen rocket_contrib serde serde_derive serde_json tera diesel r2d2 r2d2-diesel dotenv
Note that I had to edit some of the dependencies to enable certain features.
This is what my Cargo.toml
looked like after adding those edits, note that
I did not need to add diesel_codegen
:
# Cargo.toml [package] name = "voskhod" version = "0.1.0" authors = ["Alexey Zabelin <[email protected]>"] # This creates an executable that can be targeted with `cargo run` and `cargo build` # For this project, there will be two `[[bin]]` sections later on. # More information is available at https://doc.rust-lang.org/cargo/reference/manifest.html#configuring-a-target [[bin]] name = "voskhod" path = "src/bin/main.rs" # Name of the library that will be generated. [lib] name = "voskhod_lib" path = "src/lib.rs" # Dependencies added with `cargo add` and edited to enable additional features. [dependencies] rocket = "0.3.16" rocket_codegen = "0.3.16" rocket_contrib = { version = "0.3.16", default-features = false, features = ["tera_templates"] } serde = "1.0.76" serde_derive = "1.0.76" serde_json = "1.0.26" tera = "0.11.14" diesel = { version = "1.3.2", features = ["postgres"] } r2d2 = "0.8.2" r2d2-diesel = "1.0.0" dotenv = "0.13.0"
I needed to adjust the directory structure to prevent cargo build
from failing, here's what it looked like:
├── Cargo.toml └── src ├── bin │ └── main.rs └── lib.rs
Installing diesel_cli
I ran following command:
cargo install diesel_cli --no-default-features --features postgres
Then I created a .env
file with a DATABASE_URL
environment variable in it
in the root of the project for diesel_cli
to be able to connect to it.
This is what it looked like:
DATABASE_URL=postgres://username:password@localhost/voskhod
Then I ran diesel setup
to create a database for this project.
It is possible that you'd encounter errors at this stage, most likely
they'll be related to configuring your postgres users and roles.
Here's the output of that command:
Creating migrations directory at: /home/alexeyzab/code/voskhod/migrations Creating database: voskhod
The blog post I am basing this on is describing how to create a blog with users and posts. But in my case, I was going for a slightly simpler schema based on this. I created the initial migration by running the following:
diesel migration generate create_users
And the output of that command was:
Creating migrations/2018-09-08-230625_create_users/up.sql Creating migrations/2018-09-08-230625_create_users/down.sql
I edited both of those migration files:
/* up.sql */ CREATE TABLE users ( id SERIAL PRIMARY KEY, email VARCHAR UNIQUE NOT NULL, referral_code VARCHAR UNIQUE NOT NULL, referrer_id INTEGER, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP ); /* down.sql */ DROP table users;
This let me use diesel migration run
to run the migration. You can also
rerun a migration by using diesel migration redo
.
diesel migration list
showed me that the migration was complete:
Migrations: [X] 00000000000000_diesel_initial_setup [X] 2018-09-08-230625_create_users
Setting up Rocket
I started with a basic "Hello, World!" Rocket setup:
That involved changing src/bin/main.rs
to match the following:
#![feature(plugin, custom_derive)] #![plugin(rocket_codegen)] extern crate rocket; fn main() { rocket::ignite() .mount("/", routes![index]) .launch(); } #[get("/")] fn index<'a>() -> &'a str { "Hello, World!" }
I created an index
route and mounted the route at the
/
path. Rocket guide has a lot more information on the inner
workings of the framework and I'll be referring to it later on.
I used cargo run
and opened localhost:8000
in my browser to see
the "Hello, World!" text.
Setting up Tera templates
Next, I needed to configure a base Tera layout.
I created a template directory and a base layout file like so:
mkdir templates touch templates/base.html.tera
Then I edited the template file to look like this:
<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> </head> <body> <div class="container"> <p>Check out this tera Context:</p> <p>{{ my_message }}</p> </div> </body> </html>
Then it was time to hook up the template to the Rocket app by using
fairings and tera::Context
.
I edited src/bin/main.rs
to look like this:
#![feature(plugin, custom_derive)] #![plugin(rocket_codegen)] extern crate rocket; extern crate rocket_contrib; extern crate tera; use rocket_contrib::Template; use tera::Context; fn main() { rocket::ignite() .mount("/", routes![index]) .attach(Template::fairing()) .launch(); } #[get("/")] fn index() -> Template { let mut context = Context::new(); context.add("my_message", "Hello, Template World!"); // Where `base` is the name of the template Template::render("base", &context) }
Navigating to localhost:8000
now should show the new message and use the base
template.
Preparing the models
infer_schema!
doesn't seem to be needed anymore so
my src/schema.rs
looks like this:
table! { users (id) { id -> Int4, email -> Varchar, referral_code -> Varchar, referrer_id -> Nullable<Int4>, created_at -> Timestamp, updated_at -> Nullable<Timestamp>, } }
which was generated by diesel when I ran the migrations. You can also generate a table!
macro
for each of your tables manually by running diesel print-schema
, which connects to your
DB and gets all the information needed.
Next, I needed to add the actual models. When it comes to diesel, you need to have multiple models for the same table, based on whether you're querying that information or inserting it in the database.
First I ran cargo add chrono
because I am using chrono::NaiveDateTime
for the timestamps, I also needed
to add chrono
to the list of features for diesel in Cargo.toml
as mentioned
here and after that I added the following code
to src/models.rs
:
// Bringing the schema into scope use schema::users; use chrono::NaiveDateTime; #[derive(Debug, Queryable)] pub struct User { pub id: i32, pub email: String, pub referral_code: String, pub referrer_id: Option<i32>, pub created_at: NaiveDateTime, pub updated_at: Option<NaiveDateTime>, } #[derive(Debug, Insertable)] #[table_name="users"] pub struct NewUser { pub email: String, pub referral_code: String, pub created_at: NaiveDateTime, pub updated_at: Option<NaiveDateTime>, }
As you can see there are two structs: User
and NewUser
.
The first struct derives the Queryable
trait
which indicates that this record can be queried from the database. NewUser
derives the Insertable
trait which, as the name suggests, represents that a structure can be used to insert a new row into the database which is specified in the #[table_name="users"]
clause.
It's worth pointing out that you can generate the schema with diesel_cli_ext.
However, it will only generate the Queryable
model. To use diesel_cli_ext
you have to invoke it from the folder
that contains schema.rs
.
I needed to update the src/lib.rs
module to include our schema and models:
pub mod schema; pub mod models;
Trying to cargo build
then resulted in the following error:
error[E0658]: The attribute `table_name` is currently unknown to the compiler and may have meaning added to it in the future (see issue #29642) --> src/models.rs:16:3 | 16 | #[table_name="users"] | ^^^^^^^^^^ | = help: add #![feature(custom_attribute)] to the crate attributes to enable error: aborting due to previous error
This can be fixed by enabling macros for diesel in src/lib.rs
like so:
#[macro_use] extern crate diesel;
This made the build succeed, however, there were lots of warnings like these:
warning: cannot find type `users` in this scope --> src/models.rs:15:17 | 15 | #[derive(Debug, Insertable)] | ^^^^^^^^^^ names from parent modules are not accessible without an explicit import | = warning: this was previously accepted by the compiler but is being phased out; it will become a hard error in a future release! = note: for more information, see issue #50504 <https://github.com/rust-lang/rust/issues/50504>
This GitHub issue explains why these warnings appear and mentions that it's possible to mute them on a per-module basis by using the following:
#![allow(proc_macro_derive_resolution_fallback)]
I've added it to both src/models.rs
and src/schema.rs
which made the warnings disappear.
Database connection
This was fairly straightforward, the way to setup your DB connection is described in detail in Rocket's guide.
I changed my src/lib.rs
to look like this:
#![allow(proc_macro_derive_resolution_fallback)] #[macro_use] extern crate diesel; extern crate dotenv; extern crate r2d2; extern crate r2d2_diesel; extern crate rocket; extern crate rocket_contrib; extern crate chrono; pub mod schema; pub mod models; use dotenv::dotenv; use diesel::prelude::*; use r2d2::{Pool, PooledConnection}; use r2d2_diesel::ConnectionManager; use rocket::{Outcome, Request, State}; use rocket::http::Status; use rocket::request::{self, FromRequest}; use std::env; use std::ops::Deref; // Type alias for the connection pool type PgPool = Pool<ConnectionManager<PgConnection>>; pub fn create_db_pool() -> Pool<ConnectionManager<PgConnection>> { dotenv().ok(); let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); let manager = ConnectionManager::<PgConnection>::new(database_url); Pool::new(manager).expect("Failed to create pool.") } // Tuple-struct wrapper that we can write an implementation of `FromRequest` for pub struct DbConn(PooledConnection<ConnectionManager<PgConnection>>); impl<'a, 'r> FromRequest<'a, 'r> for DbConn { type Error = (); fn from_request(request: &'a Request<'r>) -> request::Outcome<Self, Self::Error> { let pool = request.guard::<State<PgPool>>()?; match pool.get() { Ok(conn) => Outcome::Success(DbConn(conn)), Err(_) => Outcome::Failure((Status::ServiceUnavailable, ())), } } } impl Deref for DbConn { type Target = PgConnection; fn deref(&self) -> &Self::Target { &self.0 } }
It is almost the same as in the "Making a simple blog with Rust", with the exception
of adjusting for a change in r2d2 API, namely the removal of Config
.
Seeding the DB
First I created src/bin/seed.rs
, then I edited Cargo.toml
to include it as an extra
binary:
[[bin]] name = "seed" path = "src/bin/seed.rs"
Trying to cargo build
now will fail since there is no main
function in src/bin/seed.rs
.
At this point I also added the fake
crate for generating the seed data:
cargo add fake
Since my schema is a bit different than the original blog post's one, I also added the following
code based on this snippet to src/lib.rs
to generate a referral code:
extern crate rand; pub fn generate_referral_code() -> String { thread_rng().sample_iter(&Alphanumeric).take(30).collect() }
Here's what I added to src/bin/seed.rs
to seed the DB with my schema:
extern crate voskhod_lib; extern crate chrono; extern crate diesel; #[macro_use] extern crate fake; use chrono::prelude::{Utc}; use diesel::prelude::*; use voskhod_lib::*; use voskhod_lib::models::*; fn main() { use schema::users::dsl::*; let connection = create_db_pool().get().unwrap(); diesel::delete(users).execute(&*connection).expect("Error deleting users"); fn generate_user_info() -> NewUser { NewUser { email: fake!(Internet.free_email), referral_code: generate_referral_code(), created_at: Utc::now().naive_utc(), updated_at: None, } } let new_user_list: Vec<NewUser> = (0..10) .map ( |_| generate_user_info() ) .collect(); diesel::insert_into(users) .values(&new_user_list) .get_results::<User>(&*connection) .expect("Error inserting users"); }
After that, running cargo run --bin seed
seeded the database, which I was able
to confirm by running psql voskhod
and then SELECT * FROM users;
.
I needed to add the information about the users to the template context. Here's
how I changed src/bin/main.rs
:
#![feature(plugin, custom_derive)] #![plugin(rocket_codegen)] extern crate voskhod_lib; extern crate diesel; extern crate rocket; extern crate rocket_contrib; extern crate tera; use diesel::prelude::*; use voskhod_lib::*; use voskhod_lib::models::*; use rocket_contrib::Template; use tera::Context; fn main() { rocket::ignite() .manage(create_db_pool()) .mount("/", routes![index]) .attach(Template::fairing()) .launch(); } #[get("/")] fn index(connection: DbConn) -> Template { use schema::users::dsl::*; let mut context = Context::new(); let user_list = users.load::<User>(&*connection).expect("Error loading users"); context.add("users", &user_list); // Where `base` is the name of the template Template::render("base", &context) }
And, similar to the original posts, I also needed to make sure the models can be serialized by
first changing the chrono
entry in Cargo.toml
:
chrono = { version = "0.4.6", features = ["serde"] }
then adding this to src/lib.rs
:
#[macro_use] extern crate serde_derive;
and changing src/models.rs
to:
#![allow(proc_macro_derive_resolution_fallback)] // Bringing the schema into scope use schema::users; use chrono::NaiveDateTime; #[derive(Debug, Queryable, Serialize)] // Now also deriving `Serialize` pub struct User { pub id: i32, pub email: String, pub referral_code: String, pub referrer_id: Option<i32>, pub created_at: NaiveDateTime, pub updated_at: Option<NaiveDateTime>, } #[derive(Debug, Insertable, Serialize)] // Now also deriving `Serialize` #[table_name = "users"] pub struct NewUser { pub email: String, pub referral_code: String, pub created_at: NaiveDateTime, pub updated_at: Option<NaiveDateTime>, }
And finally I edited the base template to display each user's email and referral code:
<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> </head> <body> <div class="container"> <h1>Users and referral codes!</h1> {% for user in users %} <p>{{ user.email }} {{ user.referral_code }}</p> {% endfor %} </div> </body> </html>
Running cargo run --bin voskhod
and navigating to localhost:8000
showed the following:
Conclusion
After all that is done, you are left with a basic web app skeleton that you can expand on. I am somewhat curious about what this setup would look like with Actix so I am planning on exploring that next.