Refactors realworld
example with multi DB support
**General** * Moves `examples/postgres/realworld` to `examples/realworld` * The app is now architected to support multiple DBs * Adds feature flags for `sqlite` and `postgres` to allow user to choose which backend to use *NOTE* Currently it is not possible to compile with `postgres` and `sqlite` enabled as we are using the `query!` and `query_as!` macros and they seem to get unhappy. * Adds CLI flags for picking the DB backend to use at runtime * Adds schema file and implementation for SQLite for `/api/user` routes * Adds stub routes and trait for articles and Articles entity **Changes** * We now use i32 instead of i64 as the user_id to get around some quirks w/ the SQLite driver. * Reimplements existing route handlers w/ an error handling shim so we can use Try inside the biz logic * *FIX* Adds a `user` key to the register user body to conform w/ realworld's API specs APIs were functionally tested using realworld's API test script (https://github.com/gothinkster/realworld/tree/master/api#authentication)
This commit is contained in:
parent
2fb38dd0c1
commit
7038dd8ab2
18 changed files with 775 additions and 255 deletions
94
Cargo.lock
generated
94
Cargo.lock
generated
|
@ -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",
|
||||
]
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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"
|
|
@ -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
|
||||
```
|
|
@ -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<PgPool>) -> 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<PgPool>) -> 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<PgPool>) -> String {
|
||||
req.header("authorization")
|
||||
.unwrap_or_default()
|
||||
.splitn(2, ' ')
|
||||
.nth(1)
|
||||
.unwrap_or_default()
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
async fn authorize(token: &str) -> anyhow::Result<i64> {
|
||||
let data = jsonwebtoken::decode::<TokenClaims>(
|
||||
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<String> {
|
||||
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<String> {
|
||||
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)
|
||||
}
|
30
examples/realworld/Cargo.toml
Normal file
30
examples/realworld/Cargo.toml
Normal file
|
@ -0,0 +1,30 @@
|
|||
[package]
|
||||
name = "sqlx-example-realworld"
|
||||
version = "0.1.0"
|
||||
authors = ["Samani G. Gikandi <samani@gojulas.com>"]
|
||||
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"] }
|
53
examples/realworld/README.md
Normal file
53
examples/realworld/README.md
Normal file
|
@ -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"}}'
|
||||
```
|
|
@ -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,
|
11
examples/realworld/schema/sqlite.sql
Normal file
11
examples/realworld/schema/sqlite.sql
Normal file
|
@ -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
|
||||
);
|
39
examples/realworld/src/api/articles.rs
Normal file
39
examples/realworld/src/api/articles.rs
Normal file
|
@ -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<impl ProvideArticle>) -> Response {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
/// Get Article
|
||||
///
|
||||
/// https://github.com/gothinkster/realworld/tree/master/api#get-article
|
||||
pub async fn get_article(req: Request<impl ProvideArticle>) -> Response {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
/// Create Article
|
||||
///
|
||||
/// https://github.com/gothinkster/realworld/tree/master/api#create-article
|
||||
pub async fn create_article(req: Request<impl ProvideArticle>) -> Response {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
/// Delete Article
|
||||
///
|
||||
/// https://github.com/gothinkster/realworld/tree/master/api#delete-article
|
||||
///
|
||||
/// /api/articles/:slug
|
||||
pub async fn update_article(req: Request<impl ProvideArticle>) -> Response {
|
||||
unimplemented!()
|
||||
}
|
36
examples/realworld/src/api/mod.rs
Normal file
36
examples/realworld/src/api/mod.rs
Normal file
|
@ -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<T> = Result<T, ApiError>;
|
||||
|
||||
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<Response> for ApiError {
|
||||
fn from(resp: Response) -> Self {
|
||||
ApiError::Api(resp)
|
||||
}
|
||||
}
|
253
examples/realworld/src/api/users.rs
Normal file
253
examples/realworld/src/api/users.rs
Normal file
|
@ -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<String>,
|
||||
pub username: String,
|
||||
pub bio: Option<String>,
|
||||
pub image: Option<String>,
|
||||
}
|
||||
|
||||
// Registration
|
||||
// https://github.com/gothinkster/realworld/tree/master/api#registration
|
||||
|
||||
// #[post("/api/users")]
|
||||
pub async fn register(req: Request<impl ProvideUser>) -> Response {
|
||||
async fn inner(mut req: Request<impl ProvideUser>) -> ApiResult<Response> {
|
||||
#[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<impl ProvideUser>) -> Response {
|
||||
async fn inner(req: Request<impl ProvideUser>) -> ApiResult<Response> {
|
||||
|
||||
// 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<impl ProvideUser>) -> Response {
|
||||
async fn inner(mut req: Request<impl ProvideUser>) -> ApiResult<Response> {
|
||||
#[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<B: serde::Serialize>(body: &B) -> Result<Response, Response> {
|
||||
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<i32> {
|
||||
let data = jsonwebtoken::decode::<TokenClaims>(
|
||||
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<String> {
|
||||
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<String> {
|
||||
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)
|
||||
}
|
10
examples/realworld/src/db/mod.rs
Normal file
10
examples/realworld/src/db/mod.rs
Normal file
|
@ -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;
|
35
examples/realworld/src/db/model.rs
Normal file
35
examples/realworld/src/db/model.rs
Normal file
|
@ -0,0 +1,35 @@
|
|||
use async_trait::async_trait;
|
||||
|
||||
pub struct UserEntity {
|
||||
pub id: i32,
|
||||
pub email: String,
|
||||
pub username: String,
|
||||
pub password: Option<String>, // 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<i32>;
|
||||
|
||||
async fn get_user_by_id(&self, user_id: i32) -> anyhow::Result<UserEntity>;
|
||||
|
||||
async fn get_user_by_email(&self, email: &str) -> anyhow::Result<UserEntity>;
|
||||
}
|
||||
|
||||
pub struct ArticleEntity {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub body: String,
|
||||
pub tag_list: Vec<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ProvideArticle {
|
||||
async fn create_article(&self) -> anyhow::Result<ArticleEntity>;
|
||||
|
||||
async fn update_article(&self) -> anyhow::Result<ArticleEntity>;
|
||||
|
||||
async fn delete_article(&self) -> anyhow::Result<ArticleEntity>;
|
||||
}
|
81
examples/realworld/src/db/pg.rs
Normal file
81
examples/realworld/src/db/pg.rs
Normal file
|
@ -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<PgPool> {
|
||||
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<i32> {
|
||||
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<UserEntity> {
|
||||
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<UserEntity> {
|
||||
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<ArticleEntity, Error> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn update_article(&self) -> Result<ArticleEntity, Error> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn delete_article(&self) -> Result<ArticleEntity, Error> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
80
examples/realworld/src/db/sqlite.rs
Normal file
80
examples/realworld/src/db/sqlite.rs
Normal file
|
@ -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<SqlitePool> {
|
||||
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<i32> {
|
||||
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<UserEntity> {
|
||||
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<UserEntity> {
|
||||
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<ArticleEntity, Error> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn update_article(&self) -> Result<ArticleEntity, Error> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn delete_article(&self) -> Result<ArticleEntity, Error> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
3
examples/realworld/src/lib.rs
Normal file
3
examples/realworld/src/lib.rs
Normal file
|
@ -0,0 +1,3 @@
|
|||
pub mod api;
|
||||
|
||||
pub mod db;
|
71
examples/realworld/src/main.rs
Normal file
71
examples/realworld/src/main.rs
Normal file
|
@ -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<S>(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))
|
||||
}
|
Loading…
Add table
Reference in a new issue