diff --git a/Cargo.lock b/Cargo.lock index ebceab782..7e682af9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -62,7 +62,7 @@ dependencies = [ "native-tls", "thiserror", "tokio 0.2.13", - "url", + "url 2.1.1", ] [[package]] @@ -123,6 +123,17 @@ dependencies = [ "winapi 0.3.8", ] +[[package]] +name = "async-trait" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "991d0a1a3e790c835fd54ab41742a59251338d8c7577fe7d7f0170c7072be708" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atty" version = "0.2.14" @@ -352,6 +363,16 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" +[[package]] +name = "cookie" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "888604f00b3db336d2af898ec3c1d5d0ddf5e6d462220f2ededc33a87ac4bbd5" +dependencies = [ + "time 0.1.42", + "url 1.7.2", +] + [[package]] name = "core-foundation" version = "0.7.0" @@ -830,6 +851,17 @@ dependencies = [ "want", ] +[[package]] +name = "idna" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38f09e0f0b1fb55fdee1f17470ad800da77af5186a1a76c026b679358b7e844e" +dependencies = [ + "matches", + "unicode-bidi", + "unicode-normalization", +] + [[package]] name = "idna" version = "0.2.0" @@ -1735,7 +1767,7 @@ dependencies = [ "sha2", "time 0.2.9", "tokio 0.2.13", - "url", + "url 2.1.1", "uuid", ] @@ -1760,23 +1792,6 @@ dependencies = [ "sqlx", ] -[[package]] -name = "sqlx-example-postgres-realworld" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-std", - "chrono", - "env_logger", - "futures 0.3.4", - "jsonwebtoken", - "rand", - "rust-argon2", - "serde", - "sqlx", - "tide", -] - [[package]] name = "sqlx-example-postgres-todos" version = "0.1.0" @@ -1789,6 +1804,27 @@ dependencies = [ "structopt", ] +[[package]] +name = "sqlx-example-realworld" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-std", + "async-trait", + "chrono", + "env_logger", + "jsonwebtoken", + "log", + "paw", + "rand", + "rust-argon2", + "serde", + "sqlx", + "structopt", + "thiserror", + "tide", +] + [[package]] name = "sqlx-example-sqlite-todos" version = "0.1.0" @@ -1815,7 +1851,7 @@ dependencies = [ "sqlx-core", "syn", "tokio 0.2.13", - "url", + "url 2.1.1", ] [[package]] @@ -2015,11 +2051,12 @@ dependencies = [ [[package]] name = "tide" -version = "0.5.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c99b1991db81e611a2614cd1b07fec89ae33c5f755e1f8eb70826fb5af0eea" +checksum = "e619c99048ae107912703d0efeec4ff4fbff704f064e51d3eee614b28ea7b739" dependencies = [ "async-std", + "cookie", "futures 0.3.4", "http", "http-service", @@ -2325,13 +2362,24 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55cd1f4b4e96b46aeb8d4855db4a7a9bd96eeeb5c6a1ab54593328761642ce2f" +[[package]] +name = "url" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd4e7c0d531266369519a4aa4f399d748bd37043b00bde1e4ff1f60a120b355a" +dependencies = [ + "idna 0.1.5", + "matches", + "percent-encoding 1.0.1", +] + [[package]] name = "url" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d4a8476c35c9bf0bbce5a3b23f4106f79728039b726d292bb93bc106787cb" dependencies = [ - "idna", + "idna 0.2.0", "matches", "percent-encoding 2.1.0", ] diff --git a/Cargo.toml b/Cargo.toml index ac721f5b2..d8701a7f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,9 +7,9 @@ members = [ "cargo-sqlx", "examples/mysql/todos", "examples/postgres/listen", - "examples/postgres/realworld", "examples/postgres/todos", "examples/sqlite/todos", + "examples/realworld", ] [package] diff --git a/examples/postgres/realworld/Cargo.toml b/examples/postgres/realworld/Cargo.toml deleted file mode 100644 index 868de7dde..000000000 --- a/examples/postgres/realworld/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "sqlx-example-postgres-realworld" -version = "0.1.0" -edition = "2018" -workspace = "../../../" - -[dependencies] -anyhow = "1.0.26" -env_logger = "0.7.1" -async-std = { version = "1.4.0", features = [ "attributes" ] } -tide = "0.5.1" -sqlx = { path = "../../../", features = [ "postgres" ] } -serde = { version = "1.0.104", features = [ "derive" ] } -futures = "0.3.1" -rust-argon2 = "0.6.1" -rand = "0.7.2" -jsonwebtoken = "6.0.1" -chrono = "0.4.10" diff --git a/examples/postgres/realworld/README.md b/examples/postgres/realworld/README.md deleted file mode 100644 index aa6c40c62..000000000 --- a/examples/postgres/realworld/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# Real World SQLx - -## Usage - -Declare the database URL. - -``` -export DATABASE_URL="postgres://postgres@localhost/realworld" -``` - -Create the database. - -``` -createdb -U postgres realworld -``` - -Load the database schema. - -``` -psql -d "$DATABASE_URL" -f ./schema.sql -``` - -Run. - -``` -cargo run -``` diff --git a/examples/postgres/realworld/src/main.rs b/examples/postgres/realworld/src/main.rs deleted file mode 100644 index 4729e6386..000000000 --- a/examples/postgres/realworld/src/main.rs +++ /dev/null @@ -1,185 +0,0 @@ -use chrono::{Duration, Utc}; -use rand::{thread_rng, RngCore}; -use sqlx::PgPool; -use std::env; -use tide::{Request, Response}; - -const SECRET_KEY: &str = "this-is-the-most-secret-key-ever-secreted"; - -// NOTE: Tide 0.5.x does not handle errors so any fallible methods just [.unwrap] for the moment. -// To be clear, that is not recommended and this should be fixed as soon as Tide fixes its -// error handling. - -#[async_std::main] -async fn main() -> anyhow::Result<()> { - let pool = PgPool::new(&env::var("DATABASE_URL")?).await?; - - let mut server = tide::with_state(pool); - - server.at("/api/users").post(register); - - server.at("/api/user").get(get_current_user); - - server.listen(("localhost", 8080)).await?; - - Ok(()) -} - -// User -// https://github.com/gothinkster/realworld/tree/master/api#users-for-authentication - -#[derive(serde::Serialize)] -struct User { - email: String, - token: String, - username: String, -} - -// Registration -// https://github.com/gothinkster/realworld/tree/master/api#registration - -// #[post("/api/users")] -async fn register(mut req: Request) -> Response { - #[derive(serde::Deserialize)] - struct RegisterRequestBody { - username: String, - email: String, - password: String, - } - - let body: RegisterRequestBody = req.body_json().await.unwrap(); - let hash = hash_password(&body.password).unwrap(); - - // Make a new transaction (for giggles) - let pool = req.state(); - let mut tx = pool.begin().await.unwrap(); - - let rec = sqlx::query!( - r#" -INSERT INTO users ( username, email, password ) -VALUES ( $1, $2, $3 ) -RETURNING id, username, email - "#, - body.username, - body.email, - hash - ) - .fetch_one(&mut tx) - .await - .unwrap(); - - let token = generate_token(rec.id).unwrap(); - - // Explicitly commit (otherwise this would rollback on drop) - tx.commit().await.unwrap(); - - #[derive(serde::Serialize)] - struct RegisterResponseBody { - user: User, - } - - Response::new(200) - .body_json(&RegisterResponseBody { - user: User { - username: rec.username, - email: rec.email, - token, - }, - }) - .unwrap() -} - -// Get Current User -// https://github.com/gothinkster/realworld/tree/master/api#get-current-user - -// #[get("/api/user")] -async fn get_current_user(req: Request) -> Response { - // TODO: Combine these methods? &Request isn't Sync though - let token = get_token_from_request(&req); - let user_id = authorize(&token).await.unwrap(); - - let pool = req.state(); - - let rec = sqlx::query!( - r#" -SELECT username, email -FROM users -WHERE id = $1 - "#, - user_id - ) - .fetch_one(pool) - .await - .unwrap(); - - #[derive(serde::Serialize)] - struct GetCurrentUserResponseBody { - user: User, - } - - Response::new(200) - .body_json(&GetCurrentUserResponseBody { - user: User { - username: rec.username, - email: rec.email, - token, - }, - }) - .unwrap() -} - -fn get_token_from_request(req: &Request) -> String { - req.header("authorization") - .unwrap_or_default() - .splitn(2, ' ') - .nth(1) - .unwrap_or_default() - .to_owned() -} - -async fn authorize(token: &str) -> anyhow::Result { - let data = jsonwebtoken::decode::( - token, - SECRET_KEY.as_ref(), - &jsonwebtoken::Validation::default(), - )?; - - Ok(data.claims.sub) -} - -// TODO: Does this need to be spawned in async-std ? -fn hash_password(password: &str) -> anyhow::Result { - let salt = generate_random_salt(); - let hash = argon2::hash_encoded(password.as_bytes(), &salt, &argon2::Config::default())?; - - Ok(hash) -} - -fn generate_random_salt() -> [u8; 16] { - let mut salt = [0; 16]; - thread_rng().fill_bytes(&mut salt); - - salt -} - -#[derive(serde::Serialize, serde::Deserialize)] -struct TokenClaims { - sub: i64, - exp: i64, -} - -fn generate_token(user_id: i64) -> anyhow::Result { - use jsonwebtoken::Header; - - let exp = Utc::now() + Duration::hours(1); - let token = jsonwebtoken::encode( - &Header::default(), - &TokenClaims { - sub: user_id, - exp: exp.timestamp(), - }, - SECRET_KEY.as_ref(), - )?; - - Ok(token) -} diff --git a/examples/realworld/Cargo.toml b/examples/realworld/Cargo.toml new file mode 100644 index 000000000..10f54f2a3 --- /dev/null +++ b/examples/realworld/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "sqlx-example-realworld" +version = "0.1.0" +authors = ["Samani G. Gikandi "] +edition = "2018" +workspace = "../../" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[features] +default = [] +sqlite = ["sqlx/sqlite"] +postgres = ["sqlx/postgres"] + +[dependencies] +anyhow = "1.0.28" +async-std = "1.5.0" +chrono = "0.4.11" +env_logger = "0.7.1" +jsonwebtoken = "6.0" +rand = "0.7.3" +rust-argon2 = "0.6.1" +serde = { version = "1.0.105", features = ["derive"] } +sqlx = { path = "../../" } +tide = "0.6.0" +log = "0.4.8" +async-trait = "0.1.27" +thiserror = "1.0.14" +paw = "1.0" +structopt = { version = "0.3", features = ["paw"] } diff --git a/examples/realworld/README.md b/examples/realworld/README.md new file mode 100644 index 000000000..ed191f695 --- /dev/null +++ b/examples/realworld/README.md @@ -0,0 +1,53 @@ +# Real World SQLx + +An implementation of ["The mother of all demo apps"](https://realworld.io/) using SQLx + +This application supports both SQLite and PostgreSQL! + +## Usage + +1. Pick a DB Backend. + + ``` + export DB_TYPE="postgres" + ``` + +2. Declare the database URL. + + ``` + export DATABASE_URL="postgres://postgres@localhost/realworld" + ``` + +3. Create the database. + + ``` + createdb -U postgres realworld + ``` + +4. Load the database schema from the appropriate file in [schema](./schema) directory. + + ``` + psql -d "${DATABASE_URL}" -f ./schema/postgres.sql + ``` + +5. Run! + + ``` + cargo run --features "${DB_TYPE}" -- --db "${DB_TYPE} + ``` + +6. Send some requests! + + ``` + curl --request POST \ + --url http://localhost:8080/api/users \ + --header 'content-type: application/json' \ + --data '{"user":{"email":"sqlx_user@foo.baz", "password":"not_secure", "username":"sqlx_user"}}' + ``` + + ``` + curl --request POST \ + --url http://localhost:8080/api/users/login \ + --header 'content-type: application/json' \ + --data '{"user":{"email":"sqlx_user@foo.baz", "password":"not_secure"}}' + ``` diff --git a/examples/postgres/realworld/schema.sql b/examples/realworld/schema/postgres.sql similarity index 78% rename from examples/postgres/realworld/schema.sql rename to examples/realworld/schema/postgres.sql index cb465e783..d692331e6 100644 --- a/examples/postgres/realworld/schema.sql +++ b/examples/realworld/schema/postgres.sql @@ -1,5 +1,5 @@ CREATE TABLE IF NOT EXISTS users ( - id BIGSERIAL PRIMARY KEY, + id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ, diff --git a/examples/realworld/schema/sqlite.sql b/examples/realworld/schema/sqlite.sql new file mode 100644 index 000000000..244fc0373 --- /dev/null +++ b/examples/realworld/schema/sqlite.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + + created_at INTEGER NOT NULL DEFAULT (STRFTIME('%s', 'now')), + updated_at INTEGER, + + email TEXT UNIQUE NOT NULL, + username TEXT UNIQUE NOT NULL, + + password TEXT +); diff --git a/examples/realworld/src/api/articles.rs b/examples/realworld/src/api/articles.rs new file mode 100644 index 000000000..380d9dcc0 --- /dev/null +++ b/examples/realworld/src/api/articles.rs @@ -0,0 +1,39 @@ +use crate::db::model::ProvideArticle; +use tide::{Request, Response}; + +struct Article { + title: String, + description: String, + body: String, + // ...etc... +} + +/// List Articles +/// +/// https://github.com/gothinkster/realworld/tree/master/api#list-articles +pub async fn list_articles(req: Request) -> Response { + unimplemented!() +} + +/// Get Article +/// +/// https://github.com/gothinkster/realworld/tree/master/api#get-article +pub async fn get_article(req: Request) -> Response { + unimplemented!() +} + +/// Create Article +/// +/// https://github.com/gothinkster/realworld/tree/master/api#create-article +pub async fn create_article(req: Request) -> Response { + unimplemented!() +} + +/// Delete Article +/// +/// https://github.com/gothinkster/realworld/tree/master/api#delete-article +/// +/// /api/articles/:slug +pub async fn update_article(req: Request) -> Response { + unimplemented!() +} diff --git a/examples/realworld/src/api/mod.rs b/examples/realworld/src/api/mod.rs new file mode 100644 index 000000000..4c6a66482 --- /dev/null +++ b/examples/realworld/src/api/mod.rs @@ -0,0 +1,36 @@ +use log::*; +use tide::{IntoResponse, Response}; + +/// Route handlers for the /api/articles APIs +pub mod articles; + +/// Route handlers for the /user(s) APIs +pub mod users; + +/// A shim error that enables ergonomic error handling w/ Tide +#[derive(Debug, thiserror::Error)] +pub enum ApiError { + #[error("Status Code {}", .0.status())] + Api(Response), + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +type ApiResult = Result; + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + match self { + ApiError::Api(r) => r, + ApiError::Other(e) => { + Response::new(500).body_string(format!("Unexpected error -- {}", e)) + } + } + } +} + +impl From for ApiError { + fn from(resp: Response) -> Self { + ApiError::Api(resp) + } +} diff --git a/examples/realworld/src/api/users.rs b/examples/realworld/src/api/users.rs new file mode 100644 index 000000000..51f2315a1 --- /dev/null +++ b/examples/realworld/src/api/users.rs @@ -0,0 +1,253 @@ +use chrono::{Duration, Utc}; +use log::*; +use rand::{thread_rng, RngCore}; +use tide::{Request, Response, IntoResponse}; + +use super::{ApiResult, ApiError}; +use crate::db::model::{ProvideUser, UserEntity}; +use std::default::Default; + +const SECRET_KEY: &str = "this-is-the-most-secret-key-ever-secreted"; + +// User +// https://github.com/gothinkster/realworld/tree/master/api#users-for-authentication +#[derive(Default, serde::Serialize)] +pub struct User { + pub email: String, + pub token: Option, + pub username: String, + pub bio: Option, + pub image: Option, +} + +// Registration +// https://github.com/gothinkster/realworld/tree/master/api#registration + +// #[post("/api/users")] +pub async fn register(req: Request) -> Response { + async fn inner(mut req: Request) -> ApiResult { + #[derive(serde::Deserialize)] + struct RegisterRequestBody { + user: NewUser + } + #[derive(serde::Deserialize)] + struct NewUser { + username: String, + email: String, + password: String, + } + + let RegisterRequestBody {user: NewUser { + username, email, password + }} = req.body_json().await + .map_err(|e| ApiError::Api(Response::new(400).body_string(e.to_string())))?; + + let hashed_password = hash_password(&password)?; + + let db = req.state(); + let id = db.create_user(&username, &email, &hashed_password).await?; + + // This is not a hard failure, the user should simply try to login + let token = generate_token(id) + .map_err(|e| { + warn!("Failed to create auth token -- {}", e); + e + }) + .ok(); + + #[derive(serde::Serialize)] + struct RegisterResponseBody { + user: User, + } + + let resp = Response::new(200) + .body_json(&RegisterResponseBody { + user: User { + email, + token, + username, + ..Default::default() + } + }) + .map_err(anyhow::Error::from)?; + + Ok(resp) + } + inner(req).await.unwrap_or_else(IntoResponse::into_response) +} + +// Get Current User +// https://github.com/gothinkster/realworld/tree/master/api#get-current-user + +// #[get("/api/user")] +pub async fn get_current_user(req: Request) -> Response { + async fn inner(req: Request) -> ApiResult { + + // FIXME(sgg): Replace this with an auth middleware? + let auth_header = req.header("authorization") + .ok_or_else(|| { + ApiError::Api(Response::new(400).body_string("Missing Authorization header".to_owned())) + })?; + + let token = get_token_from_request(auth_header); + + let user_id = authorize(&token).await + .map_err(|e| ApiError::Api(Response::new(403).body_string(format!("{}", e))))?; + + debug!("Token is authorized to user {}", user_id); + + let db = req.state(); + + let UserEntity { email, username, .. } = db.get_user_by_id(user_id).await?; + + #[derive(serde::Serialize)] + struct GetCurrentUserResponseBody { + user: User, + } + + let resp = Response::new(200) + .body_json(&GetCurrentUserResponseBody { + user: User { + email, + token: Some(token.to_owned()), + username, + ..Default::default() + }, + }) + .map_err(anyhow::Error::from)?; + + Ok(resp) + } + inner(req).await.unwrap_or_else(IntoResponse::into_response) +} + +// Login +// https://github.com/gothinkster/realworld/tree/master/api#authentication +pub async fn login(req: Request) -> Response { + async fn inner(mut req: Request) -> ApiResult { + #[derive(serde::Deserialize)] + struct LoginRequestBody { + user: Creds + } + #[derive(serde::Deserialize)] + struct Creds { + email: String, + password: String, + } + + let LoginRequestBody {user: Creds { email, password }} = req. + body_json() + .await + .map_err(|_| Response::new(400))?; + debug!("Parsed login request for {}", &email); + + debug!("Querying DB for user with email {}", &email); + let db = req.state(); + let user = db.get_user_by_email(&email) + .await + .map_err(|e| { + error!("Failed to get user -- {}", e); + e + })?; + + debug!("User {} matches email {}", user.id, &email); + + let hashed_password = user.password.as_ref() + .ok_or_else(|| Response::new(403))?; + + debug!("Authenticating user {}", user.id); + let valid = argon2::verify_encoded(hashed_password, &password.as_bytes()) + .map_err(|_| Response::new(403))?; + + if ! valid { + debug!("User {} failed authentication", user.id); + Err(Response::new(403))? + } + + debug!("Successfully authenticated {}, generating auth token", user.id); + let token = generate_token(user.id)?; + + #[derive(serde::Serialize)] + struct LoginResponseBody { + user: User + } + + let resp = to_json_response(&LoginResponseBody { + user: User { + email, + token: Some(token), + username: user.username, + ..Default::default() + } + })?; + Ok(resp) + } + inner(req).await.unwrap_or_else(IntoResponse::into_response) +} + + +/// Converts a serializable payload into a JSON response +fn to_json_response(body: &B) -> Result { + Response::new(200) + .body_json(body) + .map_err(|e| { + let error_msg = format!("Failed to serialize response -- {}", e); + warn!("{}", error_msg); + Response::new(500).body_string(error_msg) + }) +} + +fn get_token_from_request(header: &str) -> String { + header + .splitn(2, ' ') + .nth(1) + .unwrap_or_default() + .to_owned() +} + +async fn authorize(token: &str) -> anyhow::Result { + let data = jsonwebtoken::decode::( + token, + SECRET_KEY.as_ref(), + &jsonwebtoken::Validation::default(), + )?; + + Ok(data.claims.sub) +} + +// TODO: Does this need to be spawned in async-std ? +fn hash_password(password: &str) -> anyhow::Result { + let salt = generate_random_salt(); + let hash = argon2::hash_encoded(password.as_bytes(), &salt, &argon2::Config::default())?; + + Ok(hash) +} + +fn generate_random_salt() -> [u8; 16] { + let mut salt = [0; 16]; + thread_rng().fill_bytes(&mut salt); + + salt +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct TokenClaims { + sub: i32, + exp: i64, +} + +fn generate_token(user_id: i32) -> anyhow::Result { + use jsonwebtoken::Header; + + let exp = Utc::now() + Duration::hours(1); + let token = jsonwebtoken::encode( + &Header::default(), + &TokenClaims { + sub: user_id, + exp: exp.timestamp(), + }, + SECRET_KEY.as_ref(), + )?; + + Ok(token) +} diff --git a/examples/realworld/src/db/mod.rs b/examples/realworld/src/db/mod.rs new file mode 100644 index 000000000..f6d888432 --- /dev/null +++ b/examples/realworld/src/db/mod.rs @@ -0,0 +1,10 @@ +/// Database implementation for PostgreSQL +#[cfg(feature = "postgres")] +pub mod pg; + +/// Database implementation for SQLite +#[cfg(feature = "sqlite")] +pub mod sqlite; + +/// Database models +pub mod model; diff --git a/examples/realworld/src/db/model.rs b/examples/realworld/src/db/model.rs new file mode 100644 index 000000000..d56fba785 --- /dev/null +++ b/examples/realworld/src/db/model.rs @@ -0,0 +1,35 @@ +use async_trait::async_trait; + +pub struct UserEntity { + pub id: i32, + pub email: String, + pub username: String, + pub password: Option, // FIXME(RFC): Why is this nullable in the DB? +} + +/// A type that can provide [`UserEntities`] +#[async_trait] +pub trait ProvideUser { + async fn create_user(&self, username: &str, email: &str, password: &str) + -> anyhow::Result; + + async fn get_user_by_id(&self, user_id: i32) -> anyhow::Result; + + async fn get_user_by_email(&self, email: &str) -> anyhow::Result; +} + +pub struct ArticleEntity { + pub title: String, + pub description: String, + pub body: String, + pub tag_list: Vec, +} + +#[async_trait] +pub trait ProvideArticle { + async fn create_article(&self) -> anyhow::Result; + + async fn update_article(&self) -> anyhow::Result; + + async fn delete_article(&self) -> anyhow::Result; +} diff --git a/examples/realworld/src/db/pg.rs b/examples/realworld/src/db/pg.rs new file mode 100644 index 000000000..074520235 --- /dev/null +++ b/examples/realworld/src/db/pg.rs @@ -0,0 +1,81 @@ +use async_trait::async_trait; +use sqlx::PgPool; + +use super::model::*; +use anyhow::Error; + +pub async fn connect(db_url: &str) -> anyhow::Result { + let pool = PgPool::new(db_url).await?; + Ok(pool) +} + +#[async_trait] +impl ProvideUser for PgPool { + async fn create_user( + &self, + username: &str, + email: &str, + password: &str, + ) -> anyhow::Result { + let rec = sqlx::query!( + r#" +INSERT INTO users ( username, email, password ) +VALUES ( $1, $2, $3 ) +RETURNING id + "#, + username, + email, + password + ) + .fetch_one(self) + .await?; + Ok(rec.id) + } + + async fn get_user_by_id(&self, user_id: i32) -> anyhow::Result { + let rec = sqlx::query_as!( + UserEntity, + r#" +SELECT username, email, id, password +FROM users +WHERE id = $1 + "#, + user_id + ) + .fetch_one(self) + .await?; + + Ok(rec) + } + + async fn get_user_by_email(&self, email: &str) -> anyhow::Result { + let rec = sqlx::query_as!( + UserEntity, + r#" +SELECT username, email, id, password +FROM users +WHERE email = $1 + "#, + email + ) + .fetch_one(self) + .await?; + + Ok(rec) + } +} + +#[async_trait] +impl ProvideArticle for PgPool { + async fn create_article(&self) -> Result { + unimplemented!() + } + + async fn update_article(&self) -> Result { + unimplemented!() + } + + async fn delete_article(&self) -> Result { + unimplemented!() + } +} diff --git a/examples/realworld/src/db/sqlite.rs b/examples/realworld/src/db/sqlite.rs new file mode 100644 index 000000000..9565f7259 --- /dev/null +++ b/examples/realworld/src/db/sqlite.rs @@ -0,0 +1,80 @@ +use anyhow::{Result, Error}; +use async_trait::async_trait; +use sqlx::SqlitePool; + +use super::model::*; + +pub async fn connect(db_url: &str) -> anyhow::Result { + let pool = SqlitePool::new(db_url).await?; + Ok(pool) +} + +#[async_trait] +impl ProvideUser for SqlitePool { + async fn create_user(&self, username: &str, email: &str, password: &str) -> Result { + use sqlx::sqlite::SqliteQueryAs; + // Make a new transaction (for giggles) + let mut tx = self.begin().await?; + + let rows_inserted = sqlx::query!( + r#"INSERT INTO users ( username, email, password ) + VALUES ( $1, $2, $3 )"#, + username, + email, + password + ) + .execute(&mut tx) + .await?; + + let (id,) = sqlx::query_as::<_, (i32,)>(r#"SELECT LAST_INSERT_ROWID()"#) + .fetch_one(&mut tx) + .await?; + + // FIXME(sgg): Potential bug, when I forget to commit the transaction + // the sqlite locked the table forever for some reason... + // Explicitly commit (otherwise this would rollback on drop) + tx.commit().await?; + Ok(id) + } + + async fn get_user_by_id(&self, user_id: i32) -> Result { + let rec = sqlx::query_as!( + UserEntity, + r#"SELECT id, email, username, password + FROM users + WHERE id = $1"#, + user_id + ) + .fetch_one(self) + .await?; + Ok(rec) + } + + async fn get_user_by_email(&self, email: &str) -> Result { + let rec = sqlx::query_as!( + UserEntity, + r#"SELECT id, email, username, password + FROM users + WHERE email = $1"#, + email + ) + .fetch_one(self) + .await?; + Ok(rec) + } +} + +#[async_trait] +impl ProvideArticle for SqlitePool { + async fn create_article(&self) -> Result { + unimplemented!() + } + + async fn update_article(&self) -> Result { + unimplemented!() + } + + async fn delete_article(&self) -> Result { + unimplemented!() + } +} diff --git a/examples/realworld/src/lib.rs b/examples/realworld/src/lib.rs new file mode 100644 index 000000000..125768382 --- /dev/null +++ b/examples/realworld/src/lib.rs @@ -0,0 +1,3 @@ +pub mod api; + +pub mod db; diff --git a/examples/realworld/src/main.rs b/examples/realworld/src/main.rs new file mode 100644 index 000000000..90bd3cd1c --- /dev/null +++ b/examples/realworld/src/main.rs @@ -0,0 +1,71 @@ +use async_std::net::ToSocketAddrs; + +use sqlx_example_realworld::db::model::*; +use sqlx_example_realworld::{api, db}; + +#[derive(structopt::StructOpt)] +struct Args { + #[structopt(long, env = "DATABASE_URL")] + db_url: String, + #[structopt(short, long, default_value = "localhost")] + address: String, + #[structopt(short, long, default_value = "8080")] + port: u16, + #[structopt(long, default_value = "sqlite")] + db: String, +} + +async fn run_server(addr: impl ToSocketAddrs, state: S) -> anyhow::Result<()> +where + S: Send + Sync + ProvideUser + ProvideArticle + 'static, +{ + let mut server = tide::with_state(state); + + server.at("/ping").get(|_| async move { "pong" }); // FIXME(sgg): remove + + server.at("/api/users").post(api::users::register); + server.at("/api/users/login").post(api::users::login); + server.at("/api/user").get(api::users::get_current_user); + + server.at("/api/articles").get(api::articles::list_articles); + server + .at("/api/articles/:slug") + .get(api::articles::get_article) + .post(api::articles::create_article) + .put(api::articles::update_article); + + server.listen(addr).await?; + + Ok(()) +} + +async fn _main(args: Args) -> anyhow::Result<()> { + env_logger::from_env(env_logger::Env::default().default_filter_or("debug")).init(); + + let Args { + db_url, + address, + port, + db, + } = args; + + let addr = (address.as_str(), port); + + match db.as_str() { + #[cfg(feature = "sqlite")] + "sqlite" => run_server(addr, db::sqlite::connect(&db_url).await?).await, + #[cfg(feature = "postgres")] + "postgres" => run_server(addr, db::pg::connect(&db_url).await?).await, + other => Err(anyhow::anyhow!( + "Not compiled with support for DB `{}`", + other + )), + }?; + + Ok(()) +} + +#[paw::main] +fn main(args: Args) -> anyhow::Result<()> { + async_std::task::block_on(_main(args)) +}