├── .env.example ├── .github └── dependabot.yml ├── .gitignore ├── .travis.yml ├── Cargo.toml ├── LICENSE ├── README.md ├── diesel.toml ├── migrations ├── .gitkeep ├── 00000000000000_diesel_initial_setup │ ├── down.sql │ └── up.sql └── 20170211131857_create_initial_db │ ├── down.sql │ └── up.sql ├── reset.sh ├── src ├── api │ ├── auth.rs │ ├── hello.rs │ └── mod.rs ├── bin │ └── runner.rs ├── config.rs ├── database.rs ├── handlers.rs ├── lib.rs ├── models │ ├── mod.rs │ └── user.rs ├── responses.rs ├── schema.rs └── validation │ ├── mod.rs │ └── user.rs ├── tests ├── common │ └── mod.rs ├── factories │ └── mod.rs ├── test_api_auth.rs └── test_api_hello.rs └── watch.sh /.env.example: -------------------------------------------------------------------------------- 1 | export CONFIG_ENV=local 2 | export DATABASE_NAME=boilerplateapp 3 | export DATABASE_URL=postgres://localhost/boilerplateapp 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: cargo 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "04:00" 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | Cargo.lock 3 | .env 4 | .idea -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: rust 2 | 3 | rust: 4 | - stable 5 | - beta 6 | - nightly 7 | 8 | env: 9 | - DATABASE_NAME=boilerplateapp DATABASE_URL=postgres://localhost/boilerplateapp 10 | 11 | services: 12 | - postgresql 13 | 14 | matrix: 15 | allow_failures: 16 | - rust: stable 17 | - rust: beta 18 | 19 | before_script: 20 | - cargo install --force diesel_cli 21 | - cp .env.example .env 22 | - ./reset.sh 23 | - | 24 | if [[ "$TRAVIS_RUST_VERSION" == nightly && "$TRAVIS_OS_NAME" == "linux" ]]; then 25 | RUSTFLAGS="--cfg procmacro2_semver_exempt" cargo install cargo-tarpaulin 26 | fi 27 | 28 | script: 29 | - cargo build --verbose 30 | - cargo test --verbose 31 | 32 | after_success: | 33 | if [[ "$TRAVIS_RUST_VERSION" == nightly && "$TRAVIS_OS_NAME" == "linux" ]]; then 34 | cargo tarpaulin --out Xml 35 | bash <(curl -s https://codecov.io/bash) 36 | fi 37 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rust-web-boilerplate" 3 | version = "0.1.0" 4 | authors = ["Sven-Hendrik Haase "] 5 | edition = "2018" 6 | 7 | [lib] 8 | name = "rust_web_boilerplate" 9 | path = "src/lib.rs" 10 | 11 | [dependencies] 12 | uuid = { version = "0.8", features = ["serde", "v4"] } 13 | chrono = { version = "0.4", features = ["serde"] } 14 | argon2rs = "0.2" 15 | rocket = "0.4" 16 | diesel = { version = "1.4", features = ["postgres", "uuidv07", "chrono", "serde_json"] } 17 | dotenv = "0.15" 18 | serde = "1" 19 | serde_json = "1" 20 | serde_derive = "1" 21 | validator = "0.16" 22 | validator_derive = "0.16" 23 | ring = "0.13" 24 | rand = "0.7" 25 | 26 | [dev-dependencies] 27 | quickcheck = "0.9" 28 | speculate = "0.1" 29 | parking_lot = { version = "0.12", features = ["nightly"] } 30 | 31 | [dependencies.rocket_contrib] 32 | version = "0.4" 33 | default-features = false 34 | features = ["json", "diesel_postgres_pool"] 35 | 36 | [features] 37 | default = [] 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Sven-Hendrik Haase 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rust Web Boilerplate 2 | 3 | [![Build Status](https://travis-ci.org/svenstaro/rust-web-boilerplate.svg?branch=master)](https://travis-ci.org/svenstaro/rust-web-boilerplate) 4 | [![codecov](https://codecov.io/gh/svenstaro/rust-web-boilerplate/branch/master/graph/badge.svg)](https://codecov.io/gh/svenstaro/rust-web-boilerplate) 5 | [![lines of code](https://tokei.rs/b1/github/svenstaro/rust-web-boilerplate)](https://github.com/svenstaro/rust-web-boilerplate) 6 | [![license](http://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/svenstaro/rust-web-boilerplate/blob/master/LICENSE) 7 | 8 | 9 | ## About 10 | This is a boilerplate project made using best practices for getting started quickly 11 | in a new project. I made this for myself but maybe it will help someone else. Pull 12 | requests and discussions on best practices welcome! 13 | 14 | ## Development setup 15 | 16 | Install a few external dependencies and make sure `~/.cargo/bin` is in your `$PATH`: 17 | 18 | cargo install diesel_cli 19 | cargo install cargo-watch 20 | 21 | Optionally if you want line coverage from your tests, install cargo-tarpaulin: 22 | 23 | cargo-tarpaulin 24 | 25 | Copy `.env.example` to `.env` and update your application environment in this file. 26 | 27 | Make sure you have a working local postgres setup. Your current user should be 28 | admin in your development postgres installation and it should use the "peer" or 29 | "trust" auth methods (see `pg_hba.conf`). 30 | 31 | Now you can launch the `watch.sh` script which helps you quickly iterate. It 32 | will remove and recreate the DB and run the migrations and then the tests on 33 | all code changes. 34 | 35 | ./watch.sh 36 | 37 | To get line coverage, do 38 | 39 | cargo tarpaulin --ignore-tests 40 | -------------------------------------------------------------------------------- /diesel.toml: -------------------------------------------------------------------------------- 1 | # For documentation on how to configure this file, 2 | # see diesel.rs/guides/configuring-diesel-cli 3 | 4 | [print_schema] 5 | file = "src/schema.rs" 6 | with_docs = true 7 | import_types = ["diesel::sql_types::*"] 8 | -------------------------------------------------------------------------------- /migrations/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svenstaro/rust-web-boilerplate/76de5623d54f00c4bc3c23c009cef648dd1ad47c/migrations/.gitkeep -------------------------------------------------------------------------------- /migrations/00000000000000_diesel_initial_setup/down.sql: -------------------------------------------------------------------------------- 1 | DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass); 2 | DROP FUNCTION IF EXISTS diesel_set_updated_at(); 3 | -------------------------------------------------------------------------------- /migrations/00000000000000_diesel_initial_setup/up.sql: -------------------------------------------------------------------------------- 1 | -- This file was automatically created by Diesel to setup helper functions 2 | -- and other internal bookkeeping. This file is safe to edit, any future 3 | -- changes will be added to existing projects as new migrations. 4 | 5 | 6 | 7 | 8 | -- Sets up a trigger for the given table to automatically set a column called 9 | -- `updated_at` whenever the row is modified (unless `updated_at` was included 10 | -- in the modified columns) 11 | -- 12 | -- # Example 13 | -- 14 | -- ```sql 15 | -- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW()); 16 | -- 17 | -- SELECT diesel_manage_updated_at('users'); 18 | -- ``` 19 | CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$ 20 | BEGIN 21 | EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s 22 | FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl); 23 | END; 24 | $$ LANGUAGE plpgsql; 25 | 26 | CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$ 27 | BEGIN 28 | IF ( 29 | NEW IS DISTINCT FROM OLD AND 30 | NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at 31 | ) THEN 32 | NEW.updated_at := current_timestamp; 33 | END IF; 34 | RETURN NEW; 35 | END; 36 | $$ LANGUAGE plpgsql; 37 | -------------------------------------------------------------------------------- /migrations/20170211131857_create_initial_db/down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE users 2 | -------------------------------------------------------------------------------- /migrations/20170211131857_create_initial_db/up.sql: -------------------------------------------------------------------------------- 1 | CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; 2 | 3 | CREATE TABLE users ( 4 | id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), 5 | created_at TIMESTAMP DEFAULT current_timestamp NOT NULL, 6 | updated_at TIMESTAMP DEFAULT current_timestamp NOT NULL, 7 | email VARCHAR(120) UNIQUE NOT NULL, 8 | password_hash BYTEA NOT NULL, 9 | current_auth_token VARCHAR(32), 10 | last_action TIMESTAMP 11 | ); 12 | SELECT diesel_manage_updated_at('users'); 13 | 14 | CREATE UNIQUE INDEX email_idx ON users(email); 15 | CREATE UNIQUE INDEX current_auth_token_idx ON users(current_auth_token); 16 | -------------------------------------------------------------------------------- /reset.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | dropdb --if-exists ${DATABASE_NAME} 4 | diesel setup --database-url ${DATABASE_URL} 5 | -------------------------------------------------------------------------------- /src/api/auth.rs: -------------------------------------------------------------------------------- 1 | use diesel; 2 | use diesel::prelude::*; 3 | use rocket::{post, State}; 4 | use rocket_contrib::json; 5 | use rocket_contrib::json::{Json, JsonValue}; 6 | 7 | use crate::config::AppConfig; 8 | use crate::database::DbConn; 9 | use crate::models::user::{NewUser, UserModel}; 10 | use crate::responses::{ 11 | conflict, created, internal_server_error, ok, unauthorized, unprocessable_entity, APIResponse, 12 | }; 13 | use crate::schema::users; 14 | use crate::schema::users::dsl::*; 15 | use crate::validation::user::UserLogin; 16 | 17 | /// Log the user in and return a response with an auth token. 18 | /// 19 | /// Return UNAUTHORIZED in case the user can't be found or if the password is incorrect. 20 | #[post("/login", data = "", format = "application/json")] 21 | pub fn login( 22 | user_in: Json, 23 | app_config: State, 24 | db: DbConn, 25 | ) -> Result { 26 | let user_q = users 27 | .filter(email.eq(&user_in.email)) 28 | .first::(&*db) 29 | .optional()?; 30 | 31 | // For privacy reasons, we'll not provide the exact reason for failure here (although this 32 | // could probably be timing attacked to find out whether users exist or not. 33 | let mut user = 34 | user_q.ok_or_else(|| unauthorized().message("Username or password incorrect."))?; 35 | 36 | if !user.verify_password(user_in.password.as_str()) { 37 | return Err(unauthorized().message("Username or password incorrect.")); 38 | } 39 | 40 | let token = if user.has_valid_auth_token(app_config.auth_token_timeout_days) { 41 | user.current_auth_token.ok_or_else(internal_server_error)? 42 | } else { 43 | user.generate_auth_token(&db)? 44 | }; 45 | 46 | Ok(ok().data(json!({ 47 | "user_id": user.id, 48 | "token": token, 49 | }))) 50 | } 51 | 52 | /// Register a new user using email and password. 53 | /// 54 | /// Return CONFLICT is a user with the same email already exists. 55 | #[post("/register", data = "", format = "application/json")] 56 | pub fn register( 57 | user: Result, 58 | db: DbConn, 59 | ) -> Result { 60 | let user_data = user.map_err(unprocessable_entity)?; 61 | 62 | let new_password_hash = UserModel::make_password_hash(user_data.password.as_str()); 63 | let new_user = NewUser { 64 | email: user_data.email.clone(), 65 | password_hash: new_password_hash, 66 | }; 67 | 68 | let insert_result = diesel::insert_into(users::table) 69 | .values(&new_user) 70 | .get_result::(&*db); 71 | if let Err(diesel::result::Error::DatabaseError( 72 | diesel::result::DatabaseErrorKind::UniqueViolation, 73 | _, 74 | )) = insert_result 75 | { 76 | return Err(conflict().message("User already exists.")); 77 | } 78 | 79 | let user = insert_result?; 80 | Ok(created().data(json!(&user))) 81 | } 82 | -------------------------------------------------------------------------------- /src/api/hello.rs: -------------------------------------------------------------------------------- 1 | use rocket::get; 2 | use rocket_contrib::json; 3 | 4 | use crate::models::user::UserModel; 5 | use crate::responses::{ok, APIResponse}; 6 | 7 | #[get("/whoami")] 8 | pub fn whoami(current_user: UserModel) -> APIResponse { 9 | ok().data(json!(current_user.email)) 10 | } 11 | -------------------------------------------------------------------------------- /src/api/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod hello; 2 | pub mod auth; 3 | -------------------------------------------------------------------------------- /src/bin/runner.rs: -------------------------------------------------------------------------------- 1 | use dotenv::dotenv; 2 | use std::env; 3 | 4 | fn main() -> Result<(), String> { 5 | dotenv().ok(); 6 | 7 | let config_name = env::var("CONFIG_ENV").expect("CONFIG must be set"); 8 | let rocket = rust_web_boilerplate::rocket_factory(&config_name)?; 9 | rocket.launch(); 10 | Ok(()) 11 | } 12 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use rocket::config::{Config, ConfigError, Environment, Value}; 2 | use std::env; 3 | use std::collections::HashMap; 4 | use chrono::Duration; 5 | 6 | 7 | #[derive(Debug)] 8 | pub struct AppConfig { 9 | pub auth_token_timeout_days: Duration, 10 | pub cors_allow_origin: String, 11 | pub cors_allow_methods: String, 12 | pub cors_allow_headers: String, 13 | pub environment_name: String, 14 | } 15 | 16 | impl Default for AppConfig { 17 | fn default() -> AppConfig { 18 | AppConfig { 19 | auth_token_timeout_days: Duration::days(30), 20 | cors_allow_origin: String::from("*"), 21 | cors_allow_methods: String::from("*"), 22 | cors_allow_headers: String::from("*"), 23 | environment_name: String::from("unconfigured"), 24 | } 25 | } 26 | } 27 | 28 | 29 | /// Return a tuple of an app-specific config and a Rocket config. 30 | pub fn get_rocket_config(config_name: &str) -> Result<(AppConfig, Config), ConfigError> { 31 | fn production_config() -> Result<(AppConfig, Config), ConfigError> { 32 | let app_config = AppConfig { 33 | cors_allow_origin: String::from("https://example.com"), 34 | environment_name: String::from("production"), 35 | ..Default::default() 36 | }; 37 | 38 | let mut database_config = HashMap::new(); 39 | let mut databases = HashMap::new(); 40 | database_config.insert("url", Value::from(env::var("DATABASE_URL").unwrap())); 41 | databases.insert("postgres_db", Value::from(database_config)); 42 | 43 | let rocket_config = Config::build(Environment::Production) 44 | .address("0.0.0.0") 45 | .port(8080) 46 | .extra("databases", databases) 47 | .finalize()?; 48 | 49 | Ok((app_config, rocket_config)) 50 | } 51 | 52 | fn staging_config() -> Result<(AppConfig, Config), ConfigError> { 53 | let app_config = AppConfig { 54 | cors_allow_origin: String::from("https://staging.example.com"), 55 | environment_name: String::from("staging"), 56 | ..Default::default() 57 | }; 58 | 59 | let mut database_config = HashMap::new(); 60 | let mut databases = HashMap::new(); 61 | database_config.insert("url", Value::from(env::var("DATABASE_URL").unwrap())); 62 | databases.insert("postgres_db", Value::from(database_config)); 63 | 64 | let rocket_config = Config::build(Environment::Staging) 65 | .address("0.0.0.0") 66 | .port(8080) 67 | .extra("databases", databases) 68 | .finalize()?; 69 | 70 | Ok((app_config, rocket_config)) 71 | } 72 | 73 | fn develop_config() -> Result<(AppConfig, Config), ConfigError> { 74 | let app_config = AppConfig { 75 | cors_allow_origin: String::from("https://develop.example.com"), 76 | environment_name: String::from("develop"), 77 | ..Default::default() 78 | }; 79 | 80 | let mut database_config = HashMap::new(); 81 | let mut databases = HashMap::new(); 82 | database_config.insert("url", Value::from(env::var("DATABASE_URL").unwrap())); 83 | databases.insert("postgres_db", Value::from(database_config)); 84 | 85 | let rocket_config = Config::build(Environment::Staging) 86 | .address("0.0.0.0") 87 | .port(8080) 88 | .extra("databases", databases) 89 | .finalize()?; 90 | 91 | Ok((app_config, rocket_config)) 92 | } 93 | 94 | fn testing_config() -> Result<(AppConfig, Config), ConfigError> { 95 | let app_config = AppConfig { 96 | environment_name: String::from("testing"), 97 | ..Default::default() 98 | }; 99 | 100 | let mut database_config = HashMap::new(); 101 | let mut databases = HashMap::new(); 102 | database_config.insert("url", Value::from(env::var("DATABASE_URL").unwrap())); 103 | databases.insert("postgres_db", Value::from(database_config)); 104 | 105 | let rocket_config = Config::build(Environment::Staging) 106 | .address("0.0.0.0") 107 | .port(5000) 108 | .extra("databases", databases) 109 | .finalize()?; 110 | 111 | Ok((app_config, rocket_config)) 112 | } 113 | 114 | fn local_config() -> Result<(AppConfig, Config), ConfigError> { 115 | let app_config = AppConfig { 116 | environment_name: String::from("local"), 117 | ..Default::default() 118 | }; 119 | 120 | let mut database_config = HashMap::new(); 121 | let mut databases = HashMap::new(); 122 | database_config.insert("url", Value::from(env::var("DATABASE_URL").unwrap())); 123 | databases.insert("postgres_db", Value::from(database_config)); 124 | 125 | let rocket_config = Config::build(Environment::Staging) 126 | .address("0.0.0.0") 127 | .port(5000) 128 | .extra("databases", databases) 129 | .finalize()?; 130 | 131 | Ok((app_config, rocket_config)) 132 | } 133 | 134 | match config_name { 135 | "production" => production_config(), 136 | "staging" => staging_config(), 137 | "develop" => develop_config(), 138 | "testing" => testing_config(), 139 | "local" => local_config(), 140 | _ => Err(ConfigError::BadEnv(format!( 141 | "No valid config chosen: {}", 142 | config_name 143 | ))), 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/database.rs: -------------------------------------------------------------------------------- 1 | use rocket_contrib::database; 2 | 3 | #[database("postgres_db")] 4 | pub struct DbConn(diesel::PgConnection); 5 | -------------------------------------------------------------------------------- /src/handlers.rs: -------------------------------------------------------------------------------- 1 | use rocket::http::Status; 2 | use rocket::request::{self, FromRequest, Request}; 3 | use rocket::{catch, Outcome}; 4 | 5 | use crate::database::DbConn; 6 | 7 | use crate::responses::{ 8 | bad_request, forbidden, internal_server_error, not_found, service_unavailable, unauthorized, 9 | APIResponse, 10 | }; 11 | use crate::models::user::UserModel; 12 | 13 | #[catch(400)] 14 | pub fn bad_request_handler() -> APIResponse { 15 | bad_request() 16 | } 17 | 18 | #[catch(401)] 19 | pub fn unauthorized_handler() -> APIResponse { 20 | unauthorized() 21 | } 22 | 23 | #[catch(403)] 24 | pub fn forbidden_handler() -> APIResponse { 25 | forbidden() 26 | } 27 | 28 | #[catch(404)] 29 | pub fn not_found_handler() -> APIResponse { 30 | not_found() 31 | } 32 | 33 | #[catch(500)] 34 | pub fn internal_server_error_handler() -> APIResponse { 35 | internal_server_error() 36 | } 37 | 38 | #[catch(503)] 39 | pub fn service_unavailable_handler() -> APIResponse { 40 | service_unavailable() 41 | } 42 | 43 | impl<'a, 'r> FromRequest<'a, 'r> for UserModel { 44 | type Error = (); 45 | 46 | fn from_request(request: &'a Request<'r>) -> request::Outcome { 47 | let db = ::from_request(request)?; 48 | let keys: Vec<_> = request.headers().get("Authorization").collect(); 49 | if keys.len() != 1 { 50 | return Outcome::Failure((Status::BadRequest, ())); 51 | }; 52 | 53 | let token_header = keys[0]; 54 | let token = token_header.replace("Bearer ", ""); 55 | 56 | match UserModel::get_user_from_login_token(&token, &*db) { 57 | Some(user) => Outcome::Success(user), 58 | None => Outcome::Failure((Status::Unauthorized, ())), 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![feature(proc_macro_hygiene, decl_macro)] 2 | #![recursion_limit = "128"] 3 | 4 | // Keep the pre-2018 style [macro_use] for diesel because it's annoying otherwise: 5 | // https://github.com/diesel-rs/diesel/issues/1764 6 | #[macro_use] 7 | extern crate diesel; 8 | 9 | use rocket::{catchers, routes}; 10 | 11 | pub mod api; 12 | pub mod config; 13 | pub mod database; 14 | pub mod handlers; 15 | pub mod models; 16 | pub mod responses; 17 | pub mod schema; 18 | pub mod validation; 19 | 20 | /// Constructs a new Rocket instance. 21 | /// 22 | /// This function takes care of attaching all routes and handlers of the application. 23 | pub fn rocket_factory(config_name: &str) -> Result { 24 | let (app_config, rocket_config) = 25 | config::get_rocket_config(config_name).map_err(|x| format!("{}", x))?; 26 | let rocket = rocket::custom(rocket_config) 27 | .attach(database::DbConn::fairing()) 28 | .manage(app_config) 29 | .mount("/hello/", routes![api::hello::whoami]) 30 | .mount("/auth/", routes![api::auth::login, api::auth::register,]) 31 | .register(catchers![ 32 | handlers::bad_request_handler, 33 | handlers::unauthorized_handler, 34 | handlers::forbidden_handler, 35 | handlers::not_found_handler, 36 | handlers::internal_server_error_handler, 37 | handlers::service_unavailable_handler, 38 | ]); 39 | Ok(rocket) 40 | } 41 | -------------------------------------------------------------------------------- /src/models/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod user; 2 | -------------------------------------------------------------------------------- /src/models/user.rs: -------------------------------------------------------------------------------- 1 | // TODO: Silence this until diesel 1.4. 2 | // See https://github.com/diesel-rs/diesel/issues/1785#issuecomment-422579609. 3 | #![allow(proc_macro_derive_resolution_fallback)] 4 | 5 | use std::fmt; 6 | 7 | use argon2rs::argon2i_simple; 8 | use chrono::{Duration, NaiveDateTime, Utc}; 9 | use diesel::pg::PgConnection; 10 | use diesel::prelude::*; 11 | use diesel::result::Error as DieselError; 12 | use rand::distributions::Alphanumeric; 13 | use rand::{Rng, thread_rng}; 14 | use ring::constant_time::verify_slices_are_equal; 15 | use serde_derive::{Deserialize, Serialize}; 16 | use uuid::Uuid; 17 | 18 | use crate::schema::users; 19 | 20 | #[derive(Debug, Serialize, Deserialize, Queryable, Identifiable, AsChangeset)] 21 | #[table_name = "users"] 22 | pub struct UserModel { 23 | pub id: Uuid, 24 | pub created_at: NaiveDateTime, 25 | pub updated_at: NaiveDateTime, 26 | pub email: String, 27 | pub password_hash: Vec, 28 | pub current_auth_token: Option, 29 | pub last_action: Option, 30 | } 31 | 32 | #[derive(Insertable)] 33 | #[table_name = "users"] 34 | pub struct NewUser { 35 | pub email: String, 36 | pub password_hash: Vec, 37 | } 38 | 39 | impl fmt::Display for UserModel { 40 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 41 | write!(f, "", email = self.email) 42 | } 43 | } 44 | 45 | impl UserModel { 46 | /// Hash `password` using argon2 and return it. 47 | pub fn make_password_hash(password: &str) -> Vec { 48 | argon2i_simple(password, "loginsalt").to_vec() 49 | } 50 | 51 | /// Verify that `candidate_password` matches the stored password. 52 | pub fn verify_password(&self, candidate_password: &str) -> bool { 53 | let candidate_hash = argon2i_simple(candidate_password, "loginsalt").to_vec(); 54 | self.password_hash == candidate_hash 55 | } 56 | 57 | /// Generate an auth token and save it to the `current_auth_token` column. 58 | pub fn generate_auth_token(&mut self, conn: &PgConnection) -> Result { 59 | let rng = thread_rng(); 60 | let new_auth_token = rng 61 | .sample_iter(&Alphanumeric) 62 | .take(32) 63 | .collect::(); 64 | self.current_auth_token = Some(new_auth_token.clone()); 65 | self.last_action = Some(Utc::now().naive_utc()); 66 | self.save_changes::(conn)?; 67 | Ok(new_auth_token) 68 | } 69 | 70 | /// Return whether or not the user has a valid auth token. 71 | pub fn has_valid_auth_token(&self, auth_token_timeout: Duration) -> bool { 72 | let latest_valid_date = Utc::now() - auth_token_timeout; 73 | if let Some(last_action) = self.last_action { 74 | if self.current_auth_token.is_some() { 75 | last_action > latest_valid_date.naive_utc() 76 | } else { 77 | false 78 | } 79 | } else { 80 | false 81 | } 82 | } 83 | 84 | /// Get a `User` from a login token. 85 | /// 86 | /// A login token has this format: 87 | /// : 88 | pub fn get_user_from_login_token(token: &str, db: &PgConnection) -> Option { 89 | use crate::schema::users::dsl::*; 90 | 91 | let v: Vec<&str> = token.split(':').collect(); 92 | let user_id = Uuid::parse_str(v.get(0).unwrap_or(&"")).unwrap_or_default(); 93 | let auth_token = v.get(1).unwrap_or(&"").to_string(); 94 | 95 | let user = users.find(user_id).first::(&*db).optional(); 96 | if let Ok(Some(u)) = user { 97 | if let Some(token) = u.current_auth_token.clone() { 98 | if verify_slices_are_equal(token.as_bytes(), auth_token.as_bytes()).is_ok() { 99 | return Some(u); 100 | } 101 | } 102 | } 103 | None 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/responses.rs: -------------------------------------------------------------------------------- 1 | use diesel::result::Error as DieselError; 2 | use rocket::http::{ContentType, Status}; 3 | use rocket::request::Request; 4 | use rocket::response::{Responder, Response}; 5 | use rocket_contrib::json; 6 | use rocket_contrib::json::JsonValue; 7 | use std::convert::From; 8 | use std::io::Cursor; 9 | 10 | #[derive(Debug)] 11 | pub struct APIResponse { 12 | data: JsonValue, 13 | status: Status, 14 | } 15 | 16 | impl APIResponse { 17 | /// Set the data of the `Response` to `data`. 18 | pub fn data(mut self, data: JsonValue) -> APIResponse { 19 | self.data = data; 20 | self 21 | } 22 | 23 | /// Convenience method to set `self.data` to `{"message": message}`. 24 | pub fn message(mut self, message: &str) -> APIResponse { 25 | self.data = json!({ "message": message }); 26 | self 27 | } 28 | } 29 | 30 | impl From for APIResponse { 31 | fn from(_: DieselError) -> Self { 32 | internal_server_error() 33 | } 34 | } 35 | 36 | impl<'r> Responder<'r> for APIResponse { 37 | fn respond_to(self, _req: &Request) -> Result, Status> { 38 | let body = self.data; 39 | 40 | Response::build() 41 | .status(self.status) 42 | .sized_body(Cursor::new(body.to_string())) 43 | .header(ContentType::JSON) 44 | .ok() 45 | } 46 | } 47 | 48 | pub fn ok() -> APIResponse { 49 | APIResponse { 50 | data: json!(null), 51 | status: Status::Ok, 52 | } 53 | } 54 | 55 | pub fn created() -> APIResponse { 56 | APIResponse { 57 | data: json!(null), 58 | status: Status::Created, 59 | } 60 | } 61 | 62 | pub fn accepted() -> APIResponse { 63 | APIResponse { 64 | data: json!(null), 65 | status: Status::Accepted, 66 | } 67 | } 68 | 69 | pub fn no_content() -> APIResponse { 70 | APIResponse { 71 | data: json!(null), 72 | status: Status::NoContent, 73 | } 74 | } 75 | 76 | pub fn bad_request() -> APIResponse { 77 | APIResponse { 78 | data: json!({"message": "Bad Request"}), 79 | status: Status::BadRequest, 80 | } 81 | } 82 | 83 | pub fn unauthorized() -> APIResponse { 84 | APIResponse { 85 | data: json!({"message": "Unauthorized"}), 86 | status: Status::Unauthorized, 87 | } 88 | } 89 | 90 | pub fn forbidden() -> APIResponse { 91 | APIResponse { 92 | data: json!({"message": "Forbidden"}), 93 | status: Status::Forbidden, 94 | } 95 | } 96 | 97 | pub fn not_found() -> APIResponse { 98 | APIResponse { 99 | data: json!({"message": "Not Found"}), 100 | status: Status::NotFound, 101 | } 102 | } 103 | 104 | pub fn method_not_allowed() -> APIResponse { 105 | APIResponse { 106 | data: json!({"message": "Method Not Allowed"}), 107 | status: Status::MethodNotAllowed, 108 | } 109 | } 110 | 111 | pub fn conflict() -> APIResponse { 112 | APIResponse { 113 | data: json!({"message": "Conflict"}), 114 | status: Status::Conflict, 115 | } 116 | } 117 | 118 | pub fn unprocessable_entity(errors: JsonValue) -> APIResponse { 119 | APIResponse { 120 | data: json!({ "message": errors }), 121 | status: Status::UnprocessableEntity, 122 | } 123 | } 124 | 125 | pub fn internal_server_error() -> APIResponse { 126 | APIResponse { 127 | data: json!({"message": "Internal Server Error"}), 128 | status: Status::InternalServerError, 129 | } 130 | } 131 | 132 | pub fn service_unavailable() -> APIResponse { 133 | APIResponse { 134 | data: json!({"message": "Service Unavailable"}), 135 | status: Status::ServiceUnavailable, 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/schema.rs: -------------------------------------------------------------------------------- 1 | // TODO: Silence this until diesel 1.4. 2 | // See https://github.com/diesel-rs/diesel/issues/1785#issuecomment-422579609. 3 | #![allow(proc_macro_derive_resolution_fallback)] 4 | 5 | table! { 6 | users (id) { 7 | id -> Uuid, 8 | created_at -> Timestamp, 9 | updated_at -> Timestamp, 10 | email -> Varchar, 11 | password_hash -> Bytea, 12 | current_auth_token -> Nullable, 13 | last_action -> Nullable, 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/validation/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod user; 2 | -------------------------------------------------------------------------------- /src/validation/user.rs: -------------------------------------------------------------------------------- 1 | use rocket::data::{self, FromData, FromDataSimple, Transform}; 2 | use rocket::http::Status; 3 | use rocket::Outcome::*; 4 | use rocket::{Data, Request}; 5 | use rocket_contrib::json; 6 | use rocket_contrib::json::{Json, JsonValue}; 7 | use serde_derive::Deserialize; 8 | use std::collections::HashMap; 9 | use std::io::Read; 10 | use uuid::Uuid; 11 | use validator::Validate; 12 | use validator_derive::Validate; 13 | 14 | #[derive(Deserialize, Debug, Validate)] 15 | pub struct UserLogin { 16 | #[serde(skip_deserializing)] 17 | pub id: Option, 18 | #[validate(email)] 19 | pub email: String, 20 | pub password: String, 21 | } 22 | 23 | impl FromDataSimple for UserLogin { 24 | type Error = JsonValue; 25 | 26 | fn from_data(req: &Request, data: Data) -> data::Outcome { 27 | let mut d = String::new(); 28 | if data.open().read_to_string(&mut d).is_err() { 29 | return Failure(( 30 | Status::InternalServerError, 31 | json!({"_schema": "Internal server error."}), 32 | )); 33 | } 34 | let user = 35 | Json::::from_data(req, Transform::Borrowed(Success(&d))).map_failure(|_| { 36 | ( 37 | Status::UnprocessableEntity, 38 | json!({"_schema": "Error while parsing user login."}), 39 | ) 40 | })?; 41 | 42 | let mut errors = HashMap::new(); 43 | if user.email == "" { 44 | errors 45 | .entry("email") 46 | .or_insert_with(|| vec![]) 47 | .push("Must not be empty."); 48 | } else if !user.email.contains('@') || !user.email.contains('.') { 49 | errors 50 | .entry("email") 51 | .or_insert_with(|| vec![]) 52 | .push("Invalid email."); 53 | } 54 | 55 | if user.password == "" { 56 | errors 57 | .entry("password") 58 | .or_insert_with(|| vec![]) 59 | .push("Must not be empty."); 60 | } 61 | 62 | if !errors.is_empty() { 63 | return Failure((Status::UnprocessableEntity, json!(errors))); 64 | } 65 | 66 | Success(UserLogin { 67 | id: user.id, 68 | email: user.email.clone(), 69 | password: user.password.clone(), 70 | }) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /tests/common/mod.rs: -------------------------------------------------------------------------------- 1 | pub fn setup() { 2 | dotenv::dotenv().ok(); 3 | } 4 | -------------------------------------------------------------------------------- /tests/factories/mod.rs: -------------------------------------------------------------------------------- 1 | use uuid::Uuid; 2 | use diesel; 3 | use diesel::prelude::*; 4 | use diesel::pg::PgConnection; 5 | 6 | use rust_web_boilerplate::models::user::{UserModel, NewUser}; 7 | use rust_web_boilerplate::schema::users::dsl::*; 8 | 9 | /// Create a new `User` and add it to the database. 10 | /// 11 | /// The user's email will be set to '@example.com'. 12 | pub fn make_user(conn: &PgConnection) -> UserModel { 13 | let new_email = format!("{username}@example.com", username=Uuid::new_v4().to_hyphenated().to_string()); 14 | let new_password_hash = UserModel::make_password_hash("testtest"); 15 | let new_user = NewUser { 16 | email: new_email, 17 | password_hash: new_password_hash, 18 | }; 19 | 20 | diesel::insert_into(users) 21 | .values(&new_user) 22 | .get_result::(conn) 23 | .expect("Error saving new post") 24 | } 25 | -------------------------------------------------------------------------------- /tests/test_api_auth.rs: -------------------------------------------------------------------------------- 1 | #[allow(unused_imports)] 2 | use diesel::prelude::*; 3 | use parking_lot::Mutex; 4 | use rocket::http::{ContentType, Status}; 5 | use rocket::local::Client; 6 | use rocket_contrib::json; 7 | use rocket_contrib::json::JsonValue; 8 | use serde_derive::Deserialize; 9 | use speculate::speculate; 10 | use uuid::Uuid; 11 | 12 | use rust_web_boilerplate::database::DbConn; 13 | use rust_web_boilerplate::models::user::UserModel; 14 | use rust_web_boilerplate::rocket_factory; 15 | use rust_web_boilerplate::schema::users::dsl::*; 16 | 17 | use crate::factories::make_user; 18 | 19 | mod common; 20 | mod factories; 21 | 22 | static DB_LOCK: Mutex<()> = Mutex::new(()); 23 | 24 | #[derive(Deserialize)] 25 | struct LoginData { 26 | user_id: Uuid, 27 | token: String, 28 | } 29 | 30 | speculate! { 31 | before { 32 | common::setup(); 33 | let _lock = DB_LOCK.lock(); 34 | let rocket = rocket_factory("testing").unwrap(); 35 | let client = Client::new(rocket).unwrap(); 36 | #[allow(unused_variables)] 37 | let conn = DbConn::get_one(client.rocket()).expect("Failed to get a database connection for testing!"); 38 | } 39 | 40 | describe "login" { 41 | it "enables users to login and get back a valid auth token" { 42 | let user = make_user(&conn); 43 | let data = json!({ 44 | "email": user.email, 45 | "password": "testtest", 46 | }); 47 | let mut res = client.post("/auth/login") 48 | .header(ContentType::JSON) 49 | .body(data.to_string()) 50 | .dispatch(); 51 | let body: LoginData = serde_json::from_str(&res.body_string().unwrap()).unwrap(); 52 | 53 | let refreshed_user = users 54 | .find(user.id) 55 | .first::(&*conn).unwrap(); 56 | assert_eq!(res.status(), Status::Ok); 57 | assert_eq!(body.user_id, refreshed_user.id); 58 | assert_eq!(body.token, refreshed_user.current_auth_token.unwrap()); 59 | } 60 | 61 | it "can log in and get back the same auth token if there's already a valid one" { 62 | let user = make_user(&conn); 63 | let data = json!({ 64 | "email": user.email, 65 | "password": "testtest", 66 | }); 67 | 68 | // Login the first time and then retrieve and store the token. 69 | let first_login_token = { 70 | client.post("/auth/login") 71 | .header(ContentType::JSON) 72 | .body(data.to_string()) 73 | .dispatch(); 74 | let user_after_first_login = users 75 | .find(user.id) 76 | .first::(&*conn).unwrap(); 77 | user_after_first_login.current_auth_token.unwrap() 78 | }; 79 | 80 | // Login the second time and then retrieve and store the token. 81 | let second_login_token = { 82 | client.post("/auth/login") 83 | .header(ContentType::JSON) 84 | .body(data.to_string()) 85 | .dispatch(); 86 | let user_after_second_login = users 87 | .find(user.id) 88 | .first::(&*conn).unwrap(); 89 | user_after_second_login.current_auth_token.unwrap() 90 | }; 91 | 92 | assert_eq!(first_login_token, second_login_token); 93 | } 94 | 95 | it "fails with a wrong username" { 96 | make_user(&conn); 97 | let data = json!({ 98 | "email": "invalid@example.com", 99 | "password": "testtest", 100 | }); 101 | let mut res = client.post("/auth/login") 102 | .header(ContentType::JSON) 103 | .body(data.to_string()) 104 | .dispatch(); 105 | let body: JsonValue = serde_json::from_str(&res.body_string().unwrap()).unwrap(); 106 | 107 | assert_eq!(res.status(), Status::Unauthorized); 108 | assert_eq!(body["message"], "Username or password incorrect."); 109 | } 110 | 111 | it "fails with a wrong password" { 112 | let user = make_user(&conn); 113 | let data = json!({ 114 | "email": user.email, 115 | "password": "invalid", 116 | }); 117 | let mut res = client.post("/auth/login") 118 | .header(ContentType::JSON) 119 | .body(data.to_string()) 120 | .dispatch(); 121 | let body: JsonValue = serde_json::from_str(&res.body_string().unwrap()).unwrap(); 122 | 123 | assert_eq!(res.status(), Status::Unauthorized); 124 | assert_eq!(body["message"], "Username or password incorrect."); 125 | } 126 | } 127 | 128 | describe "register" { 129 | it "allows users to register a new account and then login with it" { 130 | let new_email = format!("{username}@example.com", username=Uuid::new_v4().to_hyphenated().to_string()); 131 | let new_password = "mypassword"; 132 | let data = json!({ 133 | "email": new_email, 134 | "password": new_password, 135 | }); 136 | let mut res = client.post("/auth/register") 137 | .header(ContentType::JSON) 138 | .body(data.to_string()) 139 | .dispatch(); 140 | let body: UserModel = serde_json::from_str(&res.body_string().unwrap()).unwrap(); 141 | 142 | assert_eq!(res.status(), Status::Created); 143 | assert_eq!(body.email, new_email); 144 | 145 | // Now try to log in using the new account. 146 | let data = json!({ 147 | "email": new_email, 148 | "password": new_password, 149 | }); 150 | let mut res = client.post("/auth/login") 151 | .header(ContentType::JSON) 152 | .body(data.to_string()) 153 | .dispatch(); 154 | let body: LoginData = serde_json::from_str(&res.body_string().unwrap()).unwrap(); 155 | 156 | let logged_in_user = users 157 | .filter(email.eq(new_email)) 158 | .first::(&*conn).unwrap(); 159 | assert_eq!(res.status(), Status::Ok); 160 | assert_eq!(body.token, logged_in_user.current_auth_token.unwrap()); 161 | } 162 | 163 | it "can't register with an existing email" { 164 | let new_email = format!("{username}@example.com", username=Uuid::new_v4().to_hyphenated().to_string()); 165 | let new_password = "mypassword"; 166 | let data = json!({ 167 | "email": new_email, 168 | "password": new_password, 169 | }); 170 | client.post("/auth/register") 171 | .header(ContentType::JSON) 172 | .body(data.to_string()) 173 | .dispatch(); 174 | 175 | let mut res = client.post("/auth/register") 176 | .header(ContentType::JSON) 177 | .body(data.to_string()) 178 | .dispatch(); 179 | let body: JsonValue = serde_json::from_str(&res.body_string().unwrap()).unwrap(); 180 | 181 | assert_eq!(res.status(), Status::Conflict); 182 | assert_eq!(body["message"], "User already exists."); 183 | } 184 | 185 | it "can't register with an invalid email" { 186 | let data = json!({ 187 | "email": "invalid", 188 | "password": "somepw", 189 | }); 190 | let mut res = client.post("/auth/register") 191 | .header(ContentType::JSON) 192 | .body(data.to_string()) 193 | .dispatch(); 194 | let body: JsonValue = serde_json::from_str(&res.body_string().unwrap()).unwrap(); 195 | 196 | assert_eq!(res.status(), Status::UnprocessableEntity); 197 | assert_eq!(body["message"]["email"], *json!(["Invalid email."])); 198 | } 199 | 200 | it "can't register with an empty email" { 201 | let data = json!({ 202 | "email": "", 203 | "password": "somepw", 204 | }); 205 | let mut res = client.post("/auth/register") 206 | .header(ContentType::JSON) 207 | .body(data.to_string()) 208 | .dispatch(); 209 | let body: JsonValue = serde_json::from_str(&res.body_string().unwrap()).unwrap(); 210 | 211 | assert_eq!(res.status(), Status::UnprocessableEntity); 212 | assert_eq!(body["message"]["email"], *json!(["Must not be empty."])); 213 | } 214 | 215 | it "can't register with an empty password" { 216 | let data = json!({ 217 | "email": "something@example.com", 218 | "password": "", 219 | }); 220 | let mut res = client.post("/auth/register") 221 | .header(ContentType::JSON) 222 | .body(data.to_string()) 223 | .dispatch(); 224 | let body: JsonValue = serde_json::from_str(&res.body_string().unwrap()).unwrap(); 225 | 226 | assert_eq!(res.status(), Status::UnprocessableEntity); 227 | assert_eq!(body["message"]["password"], *json!(["Must not be empty."])); 228 | } 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /tests/test_api_hello.rs: -------------------------------------------------------------------------------- 1 | #[allow(unused_imports)] 2 | use diesel::prelude::*; 3 | use parking_lot::Mutex; 4 | use rocket::http::{ContentType, Header, Status}; 5 | use rocket::local::Client; 6 | use rocket_contrib::json; 7 | use serde_derive::Deserialize; 8 | use speculate::speculate; 9 | use uuid::Uuid; 10 | 11 | use rust_web_boilerplate::database::DbConn; 12 | use rust_web_boilerplate::rocket_factory; 13 | 14 | use crate::factories::make_user; 15 | 16 | mod common; 17 | mod factories; 18 | 19 | static DB_LOCK: Mutex<()> = Mutex::new(()); 20 | 21 | #[derive(Deserialize)] 22 | struct LoginData { 23 | token: String, 24 | } 25 | 26 | speculate! { 27 | before { 28 | common::setup(); 29 | let _lock = DB_LOCK.lock(); 30 | let rocket = rocket_factory("testing").unwrap(); 31 | let client = Client::new(rocket).unwrap(); 32 | #[allow(unused_variables)] 33 | let conn = DbConn::get_one(client.rocket()).expect("Failed to get a database connection for testing!"); 34 | } 35 | 36 | describe "whoami" { 37 | it "echoes back the email" { 38 | let user = make_user(&conn); 39 | let data = json!({ 40 | "email": user.email, 41 | "password": "testtest", 42 | }); 43 | let mut res = client.post("/auth/login") 44 | .header(ContentType::JSON) 45 | .body(data.to_string()) 46 | .dispatch(); 47 | let body: LoginData = serde_json::from_str(&res.body_string().unwrap()).unwrap(); 48 | let token = body.token; 49 | 50 | let res = client.get("/hello/whoami") 51 | .header(ContentType::JSON) 52 | .header(Header::new("Authorization", format!("Bearer {}:{}", user.id, token))) 53 | .dispatch(); 54 | 55 | assert_eq!(res.status(), Status::Ok); 56 | } 57 | 58 | it "returns BadRequest when sent no Authorization header" { 59 | let user = make_user(&conn); 60 | let data = json!({ 61 | "email": user.email, 62 | "password": "testtest", 63 | }); 64 | client.post("/auth/login") 65 | .header(ContentType::JSON) 66 | .body(data.to_string()) 67 | .dispatch(); 68 | 69 | let res = client.get("/hello/whoami") 70 | .header(ContentType::JSON) 71 | .dispatch(); 72 | 73 | assert_eq!(res.status(), Status::BadRequest); 74 | } 75 | 76 | it "returns Unauthorized when sent an invalid token" { 77 | let user = make_user(&conn); 78 | let data = json!({ 79 | "email": user.email, 80 | "password": "testtest", 81 | }); 82 | client.post("/auth/login") 83 | .header(ContentType::JSON) 84 | .body(data.to_string()) 85 | .dispatch(); 86 | 87 | let res = client.get("/hello/whoami") 88 | .header(ContentType::JSON) 89 | .header(Header::new("Authorization", format!("Bearer {}:{}", user.id, Uuid::nil()))) 90 | .dispatch(); 91 | 92 | assert_eq!(res.status(), Status::Unauthorized); 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /watch.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cargo watch -s "./reset.sh && cargo clippy && cargo build && RUST_BACKTRACE=1 cargo test && RUST_BACKTRACE=1 cargo run" 3 | --------------------------------------------------------------------------------