Repository: svenstaro/rust-web-boilerplate Branch: master Commit: 76de5623d54f Files: 32 Total size: 38.4 KB Directory structure: gitextract_gpb2a5y3/ ├── .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 ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: cargo directory: "/" schedule: interval: daily time: "04:00" open-pull-requests-limit: 10 ================================================ FILE: .gitignore ================================================ target Cargo.lock .env .idea ================================================ FILE: .travis.yml ================================================ language: rust rust: - stable - beta - nightly env: - DATABASE_NAME=boilerplateapp DATABASE_URL=postgres://localhost/boilerplateapp services: - postgresql matrix: allow_failures: - rust: stable - rust: beta before_script: - cargo install --force diesel_cli - cp .env.example .env - ./reset.sh - | if [[ "$TRAVIS_RUST_VERSION" == nightly && "$TRAVIS_OS_NAME" == "linux" ]]; then RUSTFLAGS="--cfg procmacro2_semver_exempt" cargo install cargo-tarpaulin fi script: - cargo build --verbose - cargo test --verbose after_success: | if [[ "$TRAVIS_RUST_VERSION" == nightly && "$TRAVIS_OS_NAME" == "linux" ]]; then cargo tarpaulin --out Xml bash <(curl -s https://codecov.io/bash) fi ================================================ FILE: Cargo.toml ================================================ [package] name = "rust-web-boilerplate" version = "0.1.0" authors = ["Sven-Hendrik Haase "] edition = "2018" [lib] name = "rust_web_boilerplate" path = "src/lib.rs" [dependencies] uuid = { version = "0.8", features = ["serde", "v4"] } chrono = { version = "0.4", features = ["serde"] } argon2rs = "0.2" rocket = "0.4" diesel = { version = "1.4", features = ["postgres", "uuidv07", "chrono", "serde_json"] } dotenv = "0.15" serde = "1" serde_json = "1" serde_derive = "1" validator = "0.16" validator_derive = "0.16" ring = "0.13" rand = "0.7" [dev-dependencies] quickcheck = "0.9" speculate = "0.1" parking_lot = { version = "0.12", features = ["nightly"] } [dependencies.rocket_contrib] version = "0.4" default-features = false features = ["json", "diesel_postgres_pool"] [features] default = [] ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2017 Sven-Hendrik Haase Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Rust Web Boilerplate [![Build Status](https://travis-ci.org/svenstaro/rust-web-boilerplate.svg?branch=master)](https://travis-ci.org/svenstaro/rust-web-boilerplate) [![codecov](https://codecov.io/gh/svenstaro/rust-web-boilerplate/branch/master/graph/badge.svg)](https://codecov.io/gh/svenstaro/rust-web-boilerplate) [![lines of code](https://tokei.rs/b1/github/svenstaro/rust-web-boilerplate)](https://github.com/svenstaro/rust-web-boilerplate) [![license](http://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/svenstaro/rust-web-boilerplate/blob/master/LICENSE) ## About This is a boilerplate project made using best practices for getting started quickly in a new project. I made this for myself but maybe it will help someone else. Pull requests and discussions on best practices welcome! ## Development setup Install a few external dependencies and make sure `~/.cargo/bin` is in your `$PATH`: cargo install diesel_cli cargo install cargo-watch Optionally if you want line coverage from your tests, install cargo-tarpaulin: cargo-tarpaulin Copy `.env.example` to `.env` and update your application environment in this file. Make sure you have a working local postgres setup. Your current user should be admin in your development postgres installation and it should use the "peer" or "trust" auth methods (see `pg_hba.conf`). Now you can launch the `watch.sh` script which helps you quickly iterate. It will remove and recreate the DB and run the migrations and then the tests on all code changes. ./watch.sh To get line coverage, do cargo tarpaulin --ignore-tests ================================================ FILE: diesel.toml ================================================ # For documentation on how to configure this file, # see diesel.rs/guides/configuring-diesel-cli [print_schema] file = "src/schema.rs" with_docs = true import_types = ["diesel::sql_types::*"] ================================================ FILE: migrations/.gitkeep ================================================ ================================================ FILE: migrations/00000000000000_diesel_initial_setup/down.sql ================================================ DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass); DROP FUNCTION IF EXISTS diesel_set_updated_at(); ================================================ FILE: migrations/00000000000000_diesel_initial_setup/up.sql ================================================ -- This file was automatically created by Diesel to setup helper functions -- and other internal bookkeeping. This file is safe to edit, any future -- changes will be added to existing projects as new migrations. -- Sets up a trigger for the given table to automatically set a column called -- `updated_at` whenever the row is modified (unless `updated_at` was included -- in the modified columns) -- -- # Example -- -- ```sql -- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW()); -- -- SELECT diesel_manage_updated_at('users'); -- ``` CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$ BEGIN EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl); END; $$ LANGUAGE plpgsql; CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$ BEGIN IF ( NEW IS DISTINCT FROM OLD AND NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at ) THEN NEW.updated_at := current_timestamp; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; ================================================ FILE: migrations/20170211131857_create_initial_db/down.sql ================================================ DROP TABLE users ================================================ FILE: migrations/20170211131857_create_initial_db/up.sql ================================================ CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), created_at TIMESTAMP DEFAULT current_timestamp NOT NULL, updated_at TIMESTAMP DEFAULT current_timestamp NOT NULL, email VARCHAR(120) UNIQUE NOT NULL, password_hash BYTEA NOT NULL, current_auth_token VARCHAR(32), last_action TIMESTAMP ); SELECT diesel_manage_updated_at('users'); CREATE UNIQUE INDEX email_idx ON users(email); CREATE UNIQUE INDEX current_auth_token_idx ON users(current_auth_token); ================================================ FILE: reset.sh ================================================ #!/bin/bash dropdb --if-exists ${DATABASE_NAME} diesel setup --database-url ${DATABASE_URL} ================================================ FILE: src/api/auth.rs ================================================ use diesel; use diesel::prelude::*; use rocket::{post, State}; use rocket_contrib::json; use rocket_contrib::json::{Json, JsonValue}; use crate::config::AppConfig; use crate::database::DbConn; use crate::models::user::{NewUser, UserModel}; use crate::responses::{ conflict, created, internal_server_error, ok, unauthorized, unprocessable_entity, APIResponse, }; use crate::schema::users; use crate::schema::users::dsl::*; use crate::validation::user::UserLogin; /// Log the user in and return a response with an auth token. /// /// Return UNAUTHORIZED in case the user can't be found or if the password is incorrect. #[post("/login", data = "", format = "application/json")] pub fn login( user_in: Json, app_config: State, db: DbConn, ) -> Result { let user_q = users .filter(email.eq(&user_in.email)) .first::(&*db) .optional()?; // For privacy reasons, we'll not provide the exact reason for failure here (although this // could probably be timing attacked to find out whether users exist or not. let mut user = user_q.ok_or_else(|| unauthorized().message("Username or password incorrect."))?; if !user.verify_password(user_in.password.as_str()) { return Err(unauthorized().message("Username or password incorrect.")); } let token = if user.has_valid_auth_token(app_config.auth_token_timeout_days) { user.current_auth_token.ok_or_else(internal_server_error)? } else { user.generate_auth_token(&db)? }; Ok(ok().data(json!({ "user_id": user.id, "token": token, }))) } /// Register a new user using email and password. /// /// Return CONFLICT is a user with the same email already exists. #[post("/register", data = "", format = "application/json")] pub fn register( user: Result, db: DbConn, ) -> Result { let user_data = user.map_err(unprocessable_entity)?; let new_password_hash = UserModel::make_password_hash(user_data.password.as_str()); let new_user = NewUser { email: user_data.email.clone(), password_hash: new_password_hash, }; let insert_result = diesel::insert_into(users::table) .values(&new_user) .get_result::(&*db); if let Err(diesel::result::Error::DatabaseError( diesel::result::DatabaseErrorKind::UniqueViolation, _, )) = insert_result { return Err(conflict().message("User already exists.")); } let user = insert_result?; Ok(created().data(json!(&user))) } ================================================ FILE: src/api/hello.rs ================================================ use rocket::get; use rocket_contrib::json; use crate::models::user::UserModel; use crate::responses::{ok, APIResponse}; #[get("/whoami")] pub fn whoami(current_user: UserModel) -> APIResponse { ok().data(json!(current_user.email)) } ================================================ FILE: src/api/mod.rs ================================================ pub mod hello; pub mod auth; ================================================ FILE: src/bin/runner.rs ================================================ use dotenv::dotenv; use std::env; fn main() -> Result<(), String> { dotenv().ok(); let config_name = env::var("CONFIG_ENV").expect("CONFIG must be set"); let rocket = rust_web_boilerplate::rocket_factory(&config_name)?; rocket.launch(); Ok(()) } ================================================ FILE: src/config.rs ================================================ use rocket::config::{Config, ConfigError, Environment, Value}; use std::env; use std::collections::HashMap; use chrono::Duration; #[derive(Debug)] pub struct AppConfig { pub auth_token_timeout_days: Duration, pub cors_allow_origin: String, pub cors_allow_methods: String, pub cors_allow_headers: String, pub environment_name: String, } impl Default for AppConfig { fn default() -> AppConfig { AppConfig { auth_token_timeout_days: Duration::days(30), cors_allow_origin: String::from("*"), cors_allow_methods: String::from("*"), cors_allow_headers: String::from("*"), environment_name: String::from("unconfigured"), } } } /// Return a tuple of an app-specific config and a Rocket config. pub fn get_rocket_config(config_name: &str) -> Result<(AppConfig, Config), ConfigError> { fn production_config() -> Result<(AppConfig, Config), ConfigError> { let app_config = AppConfig { cors_allow_origin: String::from("https://example.com"), environment_name: String::from("production"), ..Default::default() }; let mut database_config = HashMap::new(); let mut databases = HashMap::new(); database_config.insert("url", Value::from(env::var("DATABASE_URL").unwrap())); databases.insert("postgres_db", Value::from(database_config)); let rocket_config = Config::build(Environment::Production) .address("0.0.0.0") .port(8080) .extra("databases", databases) .finalize()?; Ok((app_config, rocket_config)) } fn staging_config() -> Result<(AppConfig, Config), ConfigError> { let app_config = AppConfig { cors_allow_origin: String::from("https://staging.example.com"), environment_name: String::from("staging"), ..Default::default() }; let mut database_config = HashMap::new(); let mut databases = HashMap::new(); database_config.insert("url", Value::from(env::var("DATABASE_URL").unwrap())); databases.insert("postgres_db", Value::from(database_config)); let rocket_config = Config::build(Environment::Staging) .address("0.0.0.0") .port(8080) .extra("databases", databases) .finalize()?; Ok((app_config, rocket_config)) } fn develop_config() -> Result<(AppConfig, Config), ConfigError> { let app_config = AppConfig { cors_allow_origin: String::from("https://develop.example.com"), environment_name: String::from("develop"), ..Default::default() }; let mut database_config = HashMap::new(); let mut databases = HashMap::new(); database_config.insert("url", Value::from(env::var("DATABASE_URL").unwrap())); databases.insert("postgres_db", Value::from(database_config)); let rocket_config = Config::build(Environment::Staging) .address("0.0.0.0") .port(8080) .extra("databases", databases) .finalize()?; Ok((app_config, rocket_config)) } fn testing_config() -> Result<(AppConfig, Config), ConfigError> { let app_config = AppConfig { environment_name: String::from("testing"), ..Default::default() }; let mut database_config = HashMap::new(); let mut databases = HashMap::new(); database_config.insert("url", Value::from(env::var("DATABASE_URL").unwrap())); databases.insert("postgres_db", Value::from(database_config)); let rocket_config = Config::build(Environment::Staging) .address("0.0.0.0") .port(5000) .extra("databases", databases) .finalize()?; Ok((app_config, rocket_config)) } fn local_config() -> Result<(AppConfig, Config), ConfigError> { let app_config = AppConfig { environment_name: String::from("local"), ..Default::default() }; let mut database_config = HashMap::new(); let mut databases = HashMap::new(); database_config.insert("url", Value::from(env::var("DATABASE_URL").unwrap())); databases.insert("postgres_db", Value::from(database_config)); let rocket_config = Config::build(Environment::Staging) .address("0.0.0.0") .port(5000) .extra("databases", databases) .finalize()?; Ok((app_config, rocket_config)) } match config_name { "production" => production_config(), "staging" => staging_config(), "develop" => develop_config(), "testing" => testing_config(), "local" => local_config(), _ => Err(ConfigError::BadEnv(format!( "No valid config chosen: {}", config_name ))), } } ================================================ FILE: src/database.rs ================================================ use rocket_contrib::database; #[database("postgres_db")] pub struct DbConn(diesel::PgConnection); ================================================ FILE: src/handlers.rs ================================================ use rocket::http::Status; use rocket::request::{self, FromRequest, Request}; use rocket::{catch, Outcome}; use crate::database::DbConn; use crate::responses::{ bad_request, forbidden, internal_server_error, not_found, service_unavailable, unauthorized, APIResponse, }; use crate::models::user::UserModel; #[catch(400)] pub fn bad_request_handler() -> APIResponse { bad_request() } #[catch(401)] pub fn unauthorized_handler() -> APIResponse { unauthorized() } #[catch(403)] pub fn forbidden_handler() -> APIResponse { forbidden() } #[catch(404)] pub fn not_found_handler() -> APIResponse { not_found() } #[catch(500)] pub fn internal_server_error_handler() -> APIResponse { internal_server_error() } #[catch(503)] pub fn service_unavailable_handler() -> APIResponse { service_unavailable() } impl<'a, 'r> FromRequest<'a, 'r> for UserModel { type Error = (); fn from_request(request: &'a Request<'r>) -> request::Outcome { let db = ::from_request(request)?; let keys: Vec<_> = request.headers().get("Authorization").collect(); if keys.len() != 1 { return Outcome::Failure((Status::BadRequest, ())); }; let token_header = keys[0]; let token = token_header.replace("Bearer ", ""); match UserModel::get_user_from_login_token(&token, &*db) { Some(user) => Outcome::Success(user), None => Outcome::Failure((Status::Unauthorized, ())), } } } ================================================ FILE: src/lib.rs ================================================ #![feature(proc_macro_hygiene, decl_macro)] #![recursion_limit = "128"] // Keep the pre-2018 style [macro_use] for diesel because it's annoying otherwise: // https://github.com/diesel-rs/diesel/issues/1764 #[macro_use] extern crate diesel; use rocket::{catchers, routes}; pub mod api; pub mod config; pub mod database; pub mod handlers; pub mod models; pub mod responses; pub mod schema; pub mod validation; /// Constructs a new Rocket instance. /// /// This function takes care of attaching all routes and handlers of the application. pub fn rocket_factory(config_name: &str) -> Result { let (app_config, rocket_config) = config::get_rocket_config(config_name).map_err(|x| format!("{}", x))?; let rocket = rocket::custom(rocket_config) .attach(database::DbConn::fairing()) .manage(app_config) .mount("/hello/", routes![api::hello::whoami]) .mount("/auth/", routes![api::auth::login, api::auth::register,]) .register(catchers![ handlers::bad_request_handler, handlers::unauthorized_handler, handlers::forbidden_handler, handlers::not_found_handler, handlers::internal_server_error_handler, handlers::service_unavailable_handler, ]); Ok(rocket) } ================================================ FILE: src/models/mod.rs ================================================ pub mod user; ================================================ FILE: src/models/user.rs ================================================ // TODO: Silence this until diesel 1.4. // See https://github.com/diesel-rs/diesel/issues/1785#issuecomment-422579609. #![allow(proc_macro_derive_resolution_fallback)] use std::fmt; use argon2rs::argon2i_simple; use chrono::{Duration, NaiveDateTime, Utc}; use diesel::pg::PgConnection; use diesel::prelude::*; use diesel::result::Error as DieselError; use rand::distributions::Alphanumeric; use rand::{Rng, thread_rng}; use ring::constant_time::verify_slices_are_equal; use serde_derive::{Deserialize, Serialize}; use uuid::Uuid; use crate::schema::users; #[derive(Debug, Serialize, Deserialize, Queryable, Identifiable, AsChangeset)] #[table_name = "users"] pub struct UserModel { pub id: Uuid, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, pub email: String, pub password_hash: Vec, pub current_auth_token: Option, pub last_action: Option, } #[derive(Insertable)] #[table_name = "users"] pub struct NewUser { pub email: String, pub password_hash: Vec, } impl fmt::Display for UserModel { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "", email = self.email) } } impl UserModel { /// Hash `password` using argon2 and return it. pub fn make_password_hash(password: &str) -> Vec { argon2i_simple(password, "loginsalt").to_vec() } /// Verify that `candidate_password` matches the stored password. pub fn verify_password(&self, candidate_password: &str) -> bool { let candidate_hash = argon2i_simple(candidate_password, "loginsalt").to_vec(); self.password_hash == candidate_hash } /// Generate an auth token and save it to the `current_auth_token` column. pub fn generate_auth_token(&mut self, conn: &PgConnection) -> Result { let rng = thread_rng(); let new_auth_token = rng .sample_iter(&Alphanumeric) .take(32) .collect::(); self.current_auth_token = Some(new_auth_token.clone()); self.last_action = Some(Utc::now().naive_utc()); self.save_changes::(conn)?; Ok(new_auth_token) } /// Return whether or not the user has a valid auth token. pub fn has_valid_auth_token(&self, auth_token_timeout: Duration) -> bool { let latest_valid_date = Utc::now() - auth_token_timeout; if let Some(last_action) = self.last_action { if self.current_auth_token.is_some() { last_action > latest_valid_date.naive_utc() } else { false } } else { false } } /// Get a `User` from a login token. /// /// A login token has this format: /// : pub fn get_user_from_login_token(token: &str, db: &PgConnection) -> Option { use crate::schema::users::dsl::*; let v: Vec<&str> = token.split(':').collect(); let user_id = Uuid::parse_str(v.get(0).unwrap_or(&"")).unwrap_or_default(); let auth_token = v.get(1).unwrap_or(&"").to_string(); let user = users.find(user_id).first::(&*db).optional(); if let Ok(Some(u)) = user { if let Some(token) = u.current_auth_token.clone() { if verify_slices_are_equal(token.as_bytes(), auth_token.as_bytes()).is_ok() { return Some(u); } } } None } } ================================================ FILE: src/responses.rs ================================================ use diesel::result::Error as DieselError; use rocket::http::{ContentType, Status}; use rocket::request::Request; use rocket::response::{Responder, Response}; use rocket_contrib::json; use rocket_contrib::json::JsonValue; use std::convert::From; use std::io::Cursor; #[derive(Debug)] pub struct APIResponse { data: JsonValue, status: Status, } impl APIResponse { /// Set the data of the `Response` to `data`. pub fn data(mut self, data: JsonValue) -> APIResponse { self.data = data; self } /// Convenience method to set `self.data` to `{"message": message}`. pub fn message(mut self, message: &str) -> APIResponse { self.data = json!({ "message": message }); self } } impl From for APIResponse { fn from(_: DieselError) -> Self { internal_server_error() } } impl<'r> Responder<'r> for APIResponse { fn respond_to(self, _req: &Request) -> Result, Status> { let body = self.data; Response::build() .status(self.status) .sized_body(Cursor::new(body.to_string())) .header(ContentType::JSON) .ok() } } pub fn ok() -> APIResponse { APIResponse { data: json!(null), status: Status::Ok, } } pub fn created() -> APIResponse { APIResponse { data: json!(null), status: Status::Created, } } pub fn accepted() -> APIResponse { APIResponse { data: json!(null), status: Status::Accepted, } } pub fn no_content() -> APIResponse { APIResponse { data: json!(null), status: Status::NoContent, } } pub fn bad_request() -> APIResponse { APIResponse { data: json!({"message": "Bad Request"}), status: Status::BadRequest, } } pub fn unauthorized() -> APIResponse { APIResponse { data: json!({"message": "Unauthorized"}), status: Status::Unauthorized, } } pub fn forbidden() -> APIResponse { APIResponse { data: json!({"message": "Forbidden"}), status: Status::Forbidden, } } pub fn not_found() -> APIResponse { APIResponse { data: json!({"message": "Not Found"}), status: Status::NotFound, } } pub fn method_not_allowed() -> APIResponse { APIResponse { data: json!({"message": "Method Not Allowed"}), status: Status::MethodNotAllowed, } } pub fn conflict() -> APIResponse { APIResponse { data: json!({"message": "Conflict"}), status: Status::Conflict, } } pub fn unprocessable_entity(errors: JsonValue) -> APIResponse { APIResponse { data: json!({ "message": errors }), status: Status::UnprocessableEntity, } } pub fn internal_server_error() -> APIResponse { APIResponse { data: json!({"message": "Internal Server Error"}), status: Status::InternalServerError, } } pub fn service_unavailable() -> APIResponse { APIResponse { data: json!({"message": "Service Unavailable"}), status: Status::ServiceUnavailable, } } ================================================ FILE: src/schema.rs ================================================ // TODO: Silence this until diesel 1.4. // See https://github.com/diesel-rs/diesel/issues/1785#issuecomment-422579609. #![allow(proc_macro_derive_resolution_fallback)] table! { users (id) { id -> Uuid, created_at -> Timestamp, updated_at -> Timestamp, email -> Varchar, password_hash -> Bytea, current_auth_token -> Nullable, last_action -> Nullable, } } ================================================ FILE: src/validation/mod.rs ================================================ pub mod user; ================================================ FILE: src/validation/user.rs ================================================ use rocket::data::{self, FromData, FromDataSimple, Transform}; use rocket::http::Status; use rocket::Outcome::*; use rocket::{Data, Request}; use rocket_contrib::json; use rocket_contrib::json::{Json, JsonValue}; use serde_derive::Deserialize; use std::collections::HashMap; use std::io::Read; use uuid::Uuid; use validator::Validate; use validator_derive::Validate; #[derive(Deserialize, Debug, Validate)] pub struct UserLogin { #[serde(skip_deserializing)] pub id: Option, #[validate(email)] pub email: String, pub password: String, } impl FromDataSimple for UserLogin { type Error = JsonValue; fn from_data(req: &Request, data: Data) -> data::Outcome { let mut d = String::new(); if data.open().read_to_string(&mut d).is_err() { return Failure(( Status::InternalServerError, json!({"_schema": "Internal server error."}), )); } let user = Json::::from_data(req, Transform::Borrowed(Success(&d))).map_failure(|_| { ( Status::UnprocessableEntity, json!({"_schema": "Error while parsing user login."}), ) })?; let mut errors = HashMap::new(); if user.email == "" { errors .entry("email") .or_insert_with(|| vec![]) .push("Must not be empty."); } else if !user.email.contains('@') || !user.email.contains('.') { errors .entry("email") .or_insert_with(|| vec![]) .push("Invalid email."); } if user.password == "" { errors .entry("password") .or_insert_with(|| vec![]) .push("Must not be empty."); } if !errors.is_empty() { return Failure((Status::UnprocessableEntity, json!(errors))); } Success(UserLogin { id: user.id, email: user.email.clone(), password: user.password.clone(), }) } } ================================================ FILE: tests/common/mod.rs ================================================ pub fn setup() { dotenv::dotenv().ok(); } ================================================ FILE: tests/factories/mod.rs ================================================ use uuid::Uuid; use diesel; use diesel::prelude::*; use diesel::pg::PgConnection; use rust_web_boilerplate::models::user::{UserModel, NewUser}; use rust_web_boilerplate::schema::users::dsl::*; /// Create a new `User` and add it to the database. /// /// The user's email will be set to '@example.com'. pub fn make_user(conn: &PgConnection) -> UserModel { let new_email = format!("{username}@example.com", username=Uuid::new_v4().to_hyphenated().to_string()); let new_password_hash = UserModel::make_password_hash("testtest"); let new_user = NewUser { email: new_email, password_hash: new_password_hash, }; diesel::insert_into(users) .values(&new_user) .get_result::(conn) .expect("Error saving new post") } ================================================ FILE: tests/test_api_auth.rs ================================================ #[allow(unused_imports)] use diesel::prelude::*; use parking_lot::Mutex; use rocket::http::{ContentType, Status}; use rocket::local::Client; use rocket_contrib::json; use rocket_contrib::json::JsonValue; use serde_derive::Deserialize; use speculate::speculate; use uuid::Uuid; use rust_web_boilerplate::database::DbConn; use rust_web_boilerplate::models::user::UserModel; use rust_web_boilerplate::rocket_factory; use rust_web_boilerplate::schema::users::dsl::*; use crate::factories::make_user; mod common; mod factories; static DB_LOCK: Mutex<()> = Mutex::new(()); #[derive(Deserialize)] struct LoginData { user_id: Uuid, token: String, } speculate! { before { common::setup(); let _lock = DB_LOCK.lock(); let rocket = rocket_factory("testing").unwrap(); let client = Client::new(rocket).unwrap(); #[allow(unused_variables)] let conn = DbConn::get_one(client.rocket()).expect("Failed to get a database connection for testing!"); } describe "login" { it "enables users to login and get back a valid auth token" { let user = make_user(&conn); let data = json!({ "email": user.email, "password": "testtest", }); let mut res = client.post("/auth/login") .header(ContentType::JSON) .body(data.to_string()) .dispatch(); let body: LoginData = serde_json::from_str(&res.body_string().unwrap()).unwrap(); let refreshed_user = users .find(user.id) .first::(&*conn).unwrap(); assert_eq!(res.status(), Status::Ok); assert_eq!(body.user_id, refreshed_user.id); assert_eq!(body.token, refreshed_user.current_auth_token.unwrap()); } it "can log in and get back the same auth token if there's already a valid one" { let user = make_user(&conn); let data = json!({ "email": user.email, "password": "testtest", }); // Login the first time and then retrieve and store the token. let first_login_token = { client.post("/auth/login") .header(ContentType::JSON) .body(data.to_string()) .dispatch(); let user_after_first_login = users .find(user.id) .first::(&*conn).unwrap(); user_after_first_login.current_auth_token.unwrap() }; // Login the second time and then retrieve and store the token. let second_login_token = { client.post("/auth/login") .header(ContentType::JSON) .body(data.to_string()) .dispatch(); let user_after_second_login = users .find(user.id) .first::(&*conn).unwrap(); user_after_second_login.current_auth_token.unwrap() }; assert_eq!(first_login_token, second_login_token); } it "fails with a wrong username" { make_user(&conn); let data = json!({ "email": "invalid@example.com", "password": "testtest", }); let mut res = client.post("/auth/login") .header(ContentType::JSON) .body(data.to_string()) .dispatch(); let body: JsonValue = serde_json::from_str(&res.body_string().unwrap()).unwrap(); assert_eq!(res.status(), Status::Unauthorized); assert_eq!(body["message"], "Username or password incorrect."); } it "fails with a wrong password" { let user = make_user(&conn); let data = json!({ "email": user.email, "password": "invalid", }); let mut res = client.post("/auth/login") .header(ContentType::JSON) .body(data.to_string()) .dispatch(); let body: JsonValue = serde_json::from_str(&res.body_string().unwrap()).unwrap(); assert_eq!(res.status(), Status::Unauthorized); assert_eq!(body["message"], "Username or password incorrect."); } } describe "register" { it "allows users to register a new account and then login with it" { let new_email = format!("{username}@example.com", username=Uuid::new_v4().to_hyphenated().to_string()); let new_password = "mypassword"; let data = json!({ "email": new_email, "password": new_password, }); let mut res = client.post("/auth/register") .header(ContentType::JSON) .body(data.to_string()) .dispatch(); let body: UserModel = serde_json::from_str(&res.body_string().unwrap()).unwrap(); assert_eq!(res.status(), Status::Created); assert_eq!(body.email, new_email); // Now try to log in using the new account. let data = json!({ "email": new_email, "password": new_password, }); let mut res = client.post("/auth/login") .header(ContentType::JSON) .body(data.to_string()) .dispatch(); let body: LoginData = serde_json::from_str(&res.body_string().unwrap()).unwrap(); let logged_in_user = users .filter(email.eq(new_email)) .first::(&*conn).unwrap(); assert_eq!(res.status(), Status::Ok); assert_eq!(body.token, logged_in_user.current_auth_token.unwrap()); } it "can't register with an existing email" { let new_email = format!("{username}@example.com", username=Uuid::new_v4().to_hyphenated().to_string()); let new_password = "mypassword"; let data = json!({ "email": new_email, "password": new_password, }); client.post("/auth/register") .header(ContentType::JSON) .body(data.to_string()) .dispatch(); let mut res = client.post("/auth/register") .header(ContentType::JSON) .body(data.to_string()) .dispatch(); let body: JsonValue = serde_json::from_str(&res.body_string().unwrap()).unwrap(); assert_eq!(res.status(), Status::Conflict); assert_eq!(body["message"], "User already exists."); } it "can't register with an invalid email" { let data = json!({ "email": "invalid", "password": "somepw", }); let mut res = client.post("/auth/register") .header(ContentType::JSON) .body(data.to_string()) .dispatch(); let body: JsonValue = serde_json::from_str(&res.body_string().unwrap()).unwrap(); assert_eq!(res.status(), Status::UnprocessableEntity); assert_eq!(body["message"]["email"], *json!(["Invalid email."])); } it "can't register with an empty email" { let data = json!({ "email": "", "password": "somepw", }); let mut res = client.post("/auth/register") .header(ContentType::JSON) .body(data.to_string()) .dispatch(); let body: JsonValue = serde_json::from_str(&res.body_string().unwrap()).unwrap(); assert_eq!(res.status(), Status::UnprocessableEntity); assert_eq!(body["message"]["email"], *json!(["Must not be empty."])); } it "can't register with an empty password" { let data = json!({ "email": "something@example.com", "password": "", }); let mut res = client.post("/auth/register") .header(ContentType::JSON) .body(data.to_string()) .dispatch(); let body: JsonValue = serde_json::from_str(&res.body_string().unwrap()).unwrap(); assert_eq!(res.status(), Status::UnprocessableEntity); assert_eq!(body["message"]["password"], *json!(["Must not be empty."])); } } } ================================================ FILE: tests/test_api_hello.rs ================================================ #[allow(unused_imports)] use diesel::prelude::*; use parking_lot::Mutex; use rocket::http::{ContentType, Header, Status}; use rocket::local::Client; use rocket_contrib::json; use serde_derive::Deserialize; use speculate::speculate; use uuid::Uuid; use rust_web_boilerplate::database::DbConn; use rust_web_boilerplate::rocket_factory; use crate::factories::make_user; mod common; mod factories; static DB_LOCK: Mutex<()> = Mutex::new(()); #[derive(Deserialize)] struct LoginData { token: String, } speculate! { before { common::setup(); let _lock = DB_LOCK.lock(); let rocket = rocket_factory("testing").unwrap(); let client = Client::new(rocket).unwrap(); #[allow(unused_variables)] let conn = DbConn::get_one(client.rocket()).expect("Failed to get a database connection for testing!"); } describe "whoami" { it "echoes back the email" { let user = make_user(&conn); let data = json!({ "email": user.email, "password": "testtest", }); let mut res = client.post("/auth/login") .header(ContentType::JSON) .body(data.to_string()) .dispatch(); let body: LoginData = serde_json::from_str(&res.body_string().unwrap()).unwrap(); let token = body.token; let res = client.get("/hello/whoami") .header(ContentType::JSON) .header(Header::new("Authorization", format!("Bearer {}:{}", user.id, token))) .dispatch(); assert_eq!(res.status(), Status::Ok); } it "returns BadRequest when sent no Authorization header" { let user = make_user(&conn); let data = json!({ "email": user.email, "password": "testtest", }); client.post("/auth/login") .header(ContentType::JSON) .body(data.to_string()) .dispatch(); let res = client.get("/hello/whoami") .header(ContentType::JSON) .dispatch(); assert_eq!(res.status(), Status::BadRequest); } it "returns Unauthorized when sent an invalid token" { let user = make_user(&conn); let data = json!({ "email": user.email, "password": "testtest", }); client.post("/auth/login") .header(ContentType::JSON) .body(data.to_string()) .dispatch(); let res = client.get("/hello/whoami") .header(ContentType::JSON) .header(Header::new("Authorization", format!("Bearer {}:{}", user.id, Uuid::nil()))) .dispatch(); assert_eq!(res.status(), Status::Unauthorized); } } } ================================================ FILE: watch.sh ================================================ #!/bin/bash cargo watch -s "./reset.sh && cargo clippy && cargo build && RUST_BACKTRACE=1 cargo test && RUST_BACKTRACE=1 cargo run"