Initial commit. WIP shops endpoint

Pretty comfortable with the choice of crates now so it's time to start
committing.

Currently the API only returns errors, but throwing good errors is important.
This commit is contained in:
Tyler Hallada 2020-07-13 01:52:22 -04:00
commit 91ff001c53
11 changed files with 2806 additions and 0 deletions

2
.env Normal file
View File

@ -0,0 +1,2 @@
DATABASE_URL="postgresql://shopkeeper:K4ZJv7xzF6pioADTukDDis3ZfsgKUC@localhost/shopkeeper"
RUST_LOG="shopkeeper=debug"

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/target
**/*.rs.bk

2367
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

24
Cargo.toml Normal file
View File

@ -0,0 +1,24 @@
[package]
name = "shopkeeper"
version = "0.1.0"
authors = ["Tyler Hallada <tyler@hallada.net>"]
edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = "1.0"
chrono = { version = "0.4", features = ["serde"] }
dotenv = "0.15"
http-api-problem = { version = "0.17", features = ["with-warp"] }
hyper = "0.13"
listenfd = "0.3"
log = "0.4"
pretty_env_logger = "0.4"
tokio = { version = "0.2", features = ["macros"] }
sqlx = { version = "0.3", default-features = false, features = [ "runtime-tokio", "macros", "postgres", "chrono", "uuid" ] }
warp = { version = "0.2", features = ["compression"] }
refinery = { version = "0.3.0", features = [ "tokio-postgres", "tokio" ] }
barrel = { version = "0.6.5", features = [ "pg" ] }
clap = "3.0.0-beta.1"
serde = { version = "1.0", features = ["derive"] }

22
README.md Normal file
View File

@ -0,0 +1,22 @@
# Development Setup
1. Install and run postgres.
2. Create postgres user and database (and add uuid extension while you're there
):
createuser shopkeeper
createdb shopkeeper
sudo -u postgres -i psql
postgres=# ALTER DATABASE shopkeeper OWNER TO shopkeeper;
\password shopkeeper
postgres=# CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
3. Save password somewhere safe and then update the password in `refinery.toml`
and add a `.env` file to the project directory with the contents:
DATABASE_URL=postgresql://shopkeeper@<password>@localhost/shopkeeper
4. Run `cargo run -- -m` which will compile the app in debug mode and run the
database migrations.
5. Run `./devserver.sh` to run the dev server (by default it listens at
`0.0.0.0:3030`).
# Todo
* Make self-contained docker container that can run the app without any setup.

2
devserver.sh Executable file
View File

@ -0,0 +1,2 @@
#!/bin/sh
systemfd --no-pid -s 0.0.0.0:3030 -- cargo watch -x run

View File

@ -0,0 +1,79 @@
use barrel::{backend::Pg, types, Migration};
pub fn migration() -> String {
let mut m = Migration::new();
m.create_table("owners", |t| {
t.add_column("id", types::primary().indexed(true));
t.add_column("name", types::varchar(255));
t.add_column("api_key", types::uuid());
t.add_column("ip_address", types::varchar(45));
t.add_column("mod_version", types::varchar(25));
t.add_column("created_at", types::custom("timestamp(3)"));
t.add_column("updated_at", types::custom("timestamp(3)"));
t.add_index(
"owners_unique_name_and_api_key",
types::index(vec!["name", "api_key"]).unique(true),
);
});
m.create_table("shops", |t| {
t.add_column("id", types::primary().indexed(true));
t.add_column("name", types::varchar(255));
t.add_column("owner_id", types::foreign("owners", "id"));
t.add_column("description", types::text().nullable(true));
t.add_column("is_not_sell_buy", types::boolean().default(true));
t.add_column("sell_buy_list_id", types::integer().default(0));
t.add_column("vendor_id", types::integer());
t.add_column("vendor_gold", types::integer());
t.add_column("created_at", types::custom("timestamp(3)"));
t.add_column("updated_at", types::custom("timestamp(3)"));
t.add_index(
"shops_unique_name_and_owner_id",
types::index(vec!["name", "owner_id"]).unique(true),
);
});
m.create_table("merchandise", |t| {
t.add_column("id", types::primary().indexed(true));
t.add_column("shop_id", types::foreign("shops", "id"));
t.add_column("mod_name", types::varchar(255));
t.add_column("local_form_id", types::integer());
t.add_column("quantity", types::integer());
t.add_column("created_at", types::custom("timestamp(3)"));
t.add_column("updated_at", types::custom("timestamp(3)"));
t.add_index(
"merchandise_unique_mod_shop_id_name_and_local_form_id",
types::index(vec!["shop_id", "mod_name", "local_form_id"]).unique(true),
);
});
m.create_table("transactions", |t| {
t.add_column("id", types::primary().indexed(true));
t.add_column("shop_id", types::foreign("shops", "id"));
t.add_column("merchandise_id", types::foreign("merchandise", "id"));
t.add_column("customer_name", types::varchar(255));
t.add_column("is_customer_npc", types::boolean());
t.add_column("is_customer_buying", types::boolean());
t.add_column("quantity", types::integer());
t.add_column("is_void", types::boolean());
t.add_column("created_at", types::custom("timestamp(3)"));
});
m.create_table("interior_refs", |t| {
t.add_column("id", types::primary().indexed(true));
t.add_column("shop_id", types::foreign("shops", "id"));
t.add_column("mod_name", types::varchar(255));
t.add_column("local_form_id", types::integer());
t.add_column("position_x", types::float());
t.add_column("position_y", types::float());
t.add_column("position_z", types::float());
t.add_column("angle_x", types::float());
t.add_column("angle_y", types::float());
t.add_column("angle_z", types::float());
t.add_column("scale", types::float());
t.add_column("created_at", types::custom("timestamp(3)"));
});
m.make::<Pg>()
}

3
src/db/migrations/mod.rs Normal file
View File

@ -0,0 +1,3 @@
use refinery::include_migration_mods;
include_migration_mods!("src/db/migrations");

16
src/db/mod.rs Normal file
View File

@ -0,0 +1,16 @@
use refinery::config::Config;
mod migrations;
pub async fn migrate() {
let mut config = Config::from_file_location("src/db/refinery.toml").unwrap();
match migrations::runner().run_async(&mut config).await {
Ok(report) => {
dbg!(report.applied_migrations());
}
Err(error) => {
dbg!(error);
}
};
}

7
src/db/refinery.toml Normal file
View File

@ -0,0 +1,7 @@
[main]
db_type = "Postgres"
db_host = "localhost"
db_port = "5432"
db_user = "shopkeeper"
db_pass = "K4ZJv7xzF6pioADTukDDis3ZfsgKUC"
db_name = "shopkeeper"

282
src/main.rs Normal file
View File

@ -0,0 +1,282 @@
#[macro_use]
extern crate log;
use anyhow::Result;
use clap::Clap;
use dotenv::dotenv;
use hyper::server::Server;
use listenfd::ListenFd;
use serde::Serialize;
use sqlx::postgres::PgPool;
use std::convert::Infallible;
use std::env;
use warp::Filter;
mod db;
#[derive(Clap)]
#[clap(version = "0.1.0", author = "Tyler Hallada <tyler@hallada.net>")]
struct Opts {
#[clap(short, long)]
migrate: bool,
}
#[derive(Debug, Clone)]
pub struct Environment {
pub db: PgPool,
}
impl Environment {
async fn new() -> Result<Environment> {
Ok(Environment {
db: PgPool::builder()
.max_size(5)
.build(&env::var("DATABASE_URL")?)
.await?,
})
}
}
#[derive(Serialize)]
struct ErrorMessage {
code: u16,
message: String,
}
#[tokio::main]
async fn main() -> Result<()> {
dotenv().ok();
if env::var_os("RUST_LOG").is_none() {
env::set_var("RUST_LOG", "shopkeeper=info");
}
pretty_env_logger::init();
let opts: Opts = Opts::parse();
if opts.migrate {
info!("going to migrate now!");
db::migrate().await;
return Ok(());
}
let env = Environment::new().await?;
info!("warp speed ahead!");
// TODO: need to put everything under /api/v1/
let home = warp::path::end().map(|| "Shopkeeper home page");
let view_shop = filters::view_shop(env.clone());
let create_shop = filters::create_shop(env.clone());
let routes = create_shop
.or(view_shop)
.or(home)
.recover(problem::unpack_problem)
.with(warp::compression::gzip())
.with(warp::log("shopkeeper"));
let svc = warp::service(routes);
let make_svc = hyper::service::make_service_fn(|_: _| {
let svc = svc.clone();
async move { Ok::<_, Infallible>(svc) }
});
let mut listenfd = ListenFd::from_env();
let server = if let Some(l) = listenfd.take_tcp_listener(0)? {
Server::from_tcp(l)?
} else {
Server::bind(&([0, 0, 0, 0], 3030).into())
};
// warp::serve(routes).run(([127, 0, 0, 1], 3030)).await;
server.serve(make_svc).await?;
Ok(())
}
mod filters {
use std::convert::Infallible;
use warp::{Filter, Rejection, Reply};
use super::handlers;
use super::models::Shop;
use super::Environment;
pub fn view_shop(
env: Environment,
) -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone {
warp::path("shops")
.and(with_env(env))
.and(warp::get())
.and(warp::path::param())
.and_then(handlers::get_shop)
}
pub fn create_shop(
env: Environment,
) -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone {
warp::path("shops")
.and(with_env(env))
.and(warp::post())
.and(json_body())
.and_then(handlers::create_shop)
}
fn with_env(
env: Environment,
) -> impl Filter<Extract = (Environment,), Error = Infallible> + Clone {
warp::any().map(move || env.clone())
}
fn json_body() -> impl Filter<Extract = (Shop,), Error = warp::Rejection> + Clone {
warp::body::content_length_limit(1024 * 16).and(warp::body::json())
}
}
mod handlers {
use warp::{Rejection, Reply};
use super::models::Shop;
use super::problem::reject_anyhow;
use super::Environment;
pub async fn get_shop(env: Environment, id: i32) -> Result<impl Reply, Rejection> {
dbg!(id);
let shop = Shop::get(&env.db, id).await.map_err(reject_anyhow)?;
return Ok(format!("Shop {}: {}.", id, shop.name));
}
pub async fn create_shop(env: Environment, shop: Shop) -> Result<impl Reply, Rejection> {
dbg!(&shop);
shop.create(&env.db).await.map_err(reject_anyhow)?;
return Ok(format!("Shop {}: {}.", "unknown", &shop.name));
}
}
mod models {
use anyhow::Result;
use chrono::prelude::*;
use http_api_problem::HttpApiProblem;
use serde::{Deserialize, Serialize};
use sqlx::postgres::PgPool;
use warp::http::StatusCode;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Shop {
pub id: Option<i32>,
pub name: String,
pub owner_id: i32,
pub description: String,
pub is_not_sell_buy: bool,
pub sell_buy_list_id: i32,
pub vendor_id: i32,
pub vendor_gold: i32,
pub created_at: Option<NaiveDateTime>,
pub updated_at: Option<NaiveDateTime>,
}
impl Shop {
pub async fn get(db: &PgPool, id: i32) -> Result<Shop> {
let timer = std::time::Instant::now();
let result = sqlx::query_as!(Self, "SELECT * FROM shops WHERE id = $1", id)
.fetch_one(db)
.await?;
let elapsed = timer.elapsed();
dbg!(elapsed);
info!("SELECT * FROM shops ... | {:.3?} elapsed", elapsed);
Ok(result)
}
pub async fn create(&self, db: &PgPool) -> Result<()> {
let timer = std::time::Instant::now();
let result = sqlx::query!(
"INSERT INTO shops
(name, owner_id, description, is_not_sell_buy, sell_buy_list_id, vendor_id,
vendor_gold, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, now(), now())",
self.name,
self.owner_id,
self.description,
self.is_not_sell_buy,
self.sell_buy_list_id,
self.vendor_id,
self.vendor_gold,
)
.execute(db)
.await
.map_err(|error| {
if let sqlx::error::Error::Database(db_error) = &error {
if db_error
.message()
.contains("violates foreign key constraint \"shops_owner_id_fkey\"")
{
return anyhow::Error::new(
HttpApiProblem::with_title_and_type_from_status(
StatusCode::BAD_REQUEST,
)
.set_detail(format!("Owner with id: {} does not exist", self.owner_id)),
);
}
}
anyhow::Error::new(error)
})?;
dbg!(result);
let elapsed = timer.elapsed();
dbg!(elapsed);
info!("INSERT INTO shops ... | {:.3?} elapsed", elapsed);
Ok(())
}
}
}
mod problem {
use http_api_problem::HttpApiProblem;
use warp::http::StatusCode;
use warp::{reject, Rejection, Reply};
pub fn from_anyhow(error: anyhow::Error) -> HttpApiProblem {
let error = match error.downcast::<HttpApiProblem>() {
Ok(problem) => return problem,
Err(error) => error,
};
if let Some(sqlx_error) = error.downcast_ref::<sqlx::error::Error>() {
match sqlx_error {
sqlx::error::Error::RowNotFound => {
return HttpApiProblem::with_title_and_type_from_status(StatusCode::NOT_FOUND)
}
sqlx::error::Error::Database(db_error) => {
error!(
"Database error: {}. {}",
db_error.message(),
db_error.details().unwrap_or("")
);
}
_ => {}
}
}
// TODO: this leaks internal info, should not stringify error
HttpApiProblem::new(format!("Internal Server Error: {:?}", error))
.set_status(StatusCode::INTERNAL_SERVER_ERROR)
}
pub async fn unpack_problem(rejection: Rejection) -> Result<impl Reply, Rejection> {
if let Some(problem) = rejection.find::<HttpApiProblem>() {
let code = problem.status.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let reply = warp::reply::json(problem);
let reply = warp::reply::with_status(reply, code);
let reply = warp::reply::with_header(
reply,
warp::http::header::CONTENT_TYPE,
http_api_problem::PROBLEM_JSON_MEDIA_TYPE,
);
return Ok(reply);
}
Err(rejection)
}
pub fn reject_anyhow(error: anyhow::Error) -> Rejection {
reject::custom(from_anyhow(error))
}
}