Finishes realworld example implementation

* Adds tables for  storing articles, tags, favorites, and comments.
* Implements all remaining web APIs (articles, tags, profiles, etc)

* Refactors `Provide` traits into
* `ProvideAuthn` is used to store/retrieve user info
* `ProvideData` is used to retrieve application data
* ` Provide` traits are now implemented on Connections instead of Pools
* Introduces `Db` trait that encapsulates DB connections

* Cleans up endpoint functions
This commit is contained in:
Samani G. Gikandi 2020-04-15 10:44:40 -07:00 committed by Ryan Leckey
parent 7038dd8ab2
commit 8138a26b61
17 changed files with 2792 additions and 341 deletions

17
Cargo.lock generated
View file

@ -302,12 +302,14 @@ checksum = "130aac562c0dd69c56b3b1cc8ffd2e17be31d0b6c25b61c96b76231aa23e39e1"
name = "cargo-sqlx"
version = "0.1.0"
dependencies = [
"anyhow",
"chrono",
"dotenv",
"futures 0.3.4",
"sqlx",
"structopt",
"tokio 0.2.13",
"url 2.1.1",
]
[[package]]
@ -330,6 +332,7 @@ checksum = "80094f509cf8b5ae86a4966a39b3ff66cd7e2a3e594accec3743ff3fabeab5b2"
dependencies = [
"num-integer",
"num-traits",
"serde",
"time 0.1.42",
]
@ -897,6 +900,15 @@ version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8eca9f51da27bc908ef3dd85c21e1bbba794edaf94d7841e37356275b82d31e"
[[package]]
name = "itertools"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "284f18f85651fe11e8a991b2adb42cb078325c996ed026d994719efcfca1d54b"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "0.4.5"
@ -1813,12 +1825,17 @@ dependencies = [
"async-trait",
"chrono",
"env_logger",
"futures 0.3.4",
"heck",
"http",
"itertools",
"jsonwebtoken",
"log",
"paw",
"rand",
"rust-argon2",
"serde",
"serde_json",
"sqlx",
"structopt",
"thiserror",

View file

@ -15,16 +15,21 @@ postgres = ["sqlx/postgres"]
[dependencies]
anyhow = "1.0.28"
async-std = "1.5.0"
chrono = "0.4.11"
async-trait = "0.1.27"
chrono = { version = "0.4", features = ["serde"] }
env_logger = "0.7.1"
futures = "0.3"
heck = "0.3.1"
http = "0.1"
itertools = "0.9.0"
jsonwebtoken = "6.0"
log = "0.4.8"
paw = "1.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"
serde_json = "1.0"
sqlx = { path = "../../", features = ["chrono"]}
structopt = { version = "0.3", features = ["paw"] }
thiserror = "1.0.14"
tide = "0.6.0"

View file

@ -51,3 +51,23 @@ This application supports both SQLite and PostgreSQL!
--header 'content-type: application/json' \
--data '{"user":{"email":"sqlx_user@foo.baz", "password":"not_secure"}}'
```
## Unimplemented Features
* Filters via query parameters
* Unit tests
## Known Issues/Quirks
* This is not a production application, pks are public ids, caveat emptor, etc.
* Currently you CANNOT compile this crate with multiple DB backends enabled as the query macros
will conflict with one another.
* SQLite locks the tables if there are basically any errors (e.g. constraint violations). This may be related to
[#193](https://github.com/launchbadge/sqlx/issues/193)
* The realworld API tests complain about timestamps in our responses.
This is an issue w/ their tests [gothinkster/realworld#490]https://github.com/gothinkster/realworld/pull/490
* As of `0.6.0`, `tide` has not fully worked out the error handling story.
`tide::ResultExt` helps but as of now API endpoint functions can only return `tide::Response`
* `sqlx::Error` does not carry type information about the Database so some clever downcasting
is needed to resolve details from Database errors

View file

@ -1,11 +1,70 @@
CREATE TABLE IF NOT EXISTS users (
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
DROP VIEW IF EXISTS profiles CASCADE ;
DROP TABLE users, followers, articles, favorite_articles, tags, comments CASCADE ;
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ,
CREATE TABLE IF NOT EXISTS users (
user_id INT NOT NULL GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
username TEXT UNIQUE NOT NULL,
bio TEXT,
image TEXT,
password TEXT
created_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() AT TIME ZONE 'utc'),
updated_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() AT TIME ZONE 'utc')
);
-- This is implemented as a view for demonstration purposes
CREATE VIEW profiles AS
SELECT user_id, username, bio, image
FROM users;
CREATE TABLE IF NOT EXISTS articles (
article_id INT NOT NULL GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
title TEXT UNIQUE NOT NULL,
description TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
body TEXT NOT NULL,
author_id INT NOT NULL REFERENCES users (user_id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() AT TIME ZONE 'utc'),
updated_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() AT TIME ZONE 'utc')
);
-- many queries are performed via slug
CREATE INDEX ON articles (slug);
CREATE TABLE IF NOT EXISTS followers (
leader_id INT NOT NULL,
follower_id INT NOT NULL,
FOREIGN KEY (leader_id) REFERENCES users (user_id) ON DELETE CASCADE,
FOREIGN KEY (follower_id) REFERENCES users (user_id) ON DELETE CASCADE,
UNIQUE (leader_id, follower_id)
);
CREATE TABLE IF NOT EXISTS favorite_articles (
user_id INT NOT NULL REFERENCES users (user_id) ON DELETE CASCADE,
article_id INT NOT NULL REFERENCES articles (article_id) ON DELETE CASCADE,
UNIQUE (user_id, article_id)
);
CREATE TABLE IF NOT EXISTS tags (
tag_name TEXT NOT NULL,
article_id INT NOT NULL REFERENCES articles (article_id) ON DELETE CASCADE,
UNIQUE (tag_name, article_id)
);
CREATE TABLE IF NOT EXISTS comments (
comment_id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
body TEXT NOT NULL,
article_id INT NOT NULL REFERENCES articles (article_id) ON DELETE CASCADE,
author_id INT NOT NULL REFERENCES users (user_id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() AT TIME ZONE 'utc'),
updated_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() AT TIME ZONE 'utc')
);

View file

@ -1,11 +1,67 @@
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,
user_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
username TEXT UNIQUE NOT NULL,
bio TEXT,
image TEXT,
password TEXT
created_at INTEGER NOT NULL DEFAULT (STRFTIME('%s', 'now')),
updated_at INTEGER NOT NULL DEFAULT (STRFTIME('%s', 'now'))
);
CREATE VIEW profiles AS
SELECT user_id, username, bio, image
FROM users;
CREATE TABLE IF NOT EXISTS articles (
article_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
title TEXT UNIQUE NOT NULL,
description TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
body TEXT NOT NULL,
author_id INTEGER NOT NULL REFERENCES users (user_id) ON DELETE CASCADE ,
created_at INTEGER NOT NULL DEFAULT (STRFTIME('%s', 'now')),
updated_at INTEGER NOT NULL DEFAULT (STRFTIME('%s', 'now'))
);
CREATE INDEX idx_articles_slug ON articles (slug);
CREATE TABLE IF NOT EXISTS followers (
leader_id INTEGER NOT NULL,
follower_id INTEGER NOT NULL,
FOREIGN KEY (leader_id) REFERENCES users (user_id) ON DELETE CASCADE,
FOREIGN KEY (follower_id) REFERENCES users (user_id) ON DELETE CASCADE,
UNIQUE (leader_id, follower_id)
);
CREATE TABLE IF NOT EXISTS favorite_articles (
user_id INTEGER NOT NULL REFERENCES users (user_id) ON DELETE CASCADE,
article_id INTEGER NOT NULL REFERENCES articles (article_id) ON DELETE CASCADE,
UNIQUE (user_id, article_id)
);
CREATE TABLE IF NOT EXISTS tags (
tag_name TEXT NOT NULL,
article_id INTEGER NOT NULL REFERENCES articles (article_id) ON DELETE CASCADE,
UNIQUE (tag_name, article_id)
);
CREATE TABLE IF NOT EXISTS comments (
comment_id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
body TEXT NOT NULL,
article_id INT NOT NULL REFERENCES articles (article_id) ON DELETE CASCADE,
author_id INT NOT NULL REFERENCES users (user_id) ON DELETE CASCADE,
created_at INTEGER NOT NULL DEFAULT (STRFTIME('%s', 'now')),
updated_at INTEGER NOT NULL DEFAULT (STRFTIME('%s', 'now'))
);

View file

@ -1,39 +1,615 @@
use crate::db::model::ProvideArticle;
use tide::{Request, Response};
use chrono::{DateTime, Utc};
use futures::TryFutureExt;
use heck::KebabCase;
use log::*;
use serde::{Deserialize, Serialize};
use sqlx::pool::PoolConnection;
use sqlx::{Connect, Connection};
use tide::{Error, IntoResponse, Request, Response, ResultExt};
struct Article {
title: String,
description: String,
body: String,
// ...etc...
use crate::api::model::*;
use crate::api::util::*;
use crate::db::model::{ArticleEntity, CommentEntity, EntityId, ProfileEntity, ProvideData};
use crate::db::Db;
use std::collections::HashSet;
use std::iter::FromIterator;
/// The response body for a single article
///
/// [API Spec](https://github.com/gothinkster/realworld/tree/master/api#single-article)
#[derive(Serialize)]
struct ArticleResponseBody {
article: Article,
}
/// List Articles
/// The response body for multiple articles
///
/// https://github.com/gothinkster/realworld/tree/master/api#list-articles
pub async fn list_articles(req: Request<impl ProvideArticle>) -> Response {
unimplemented!()
/// [API Spec](https://github.com/gothinkster/realworld/tree/master/api#multiple-comments)
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct MultArticlesResponseBody {
articles: Vec<Article>,
articles_count: usize,
}
impl From<Vec<Article>> for MultArticlesResponseBody {
fn from(articles: Vec<Article>) -> Self {
let articles_count = articles.len();
Self {
articles,
articles_count,
}
}
}
/// A comment on an article
///
/// [API Spec](https://github.com/gothinkster/realworld/tree/master/api#single-comment)
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Comment {
id: u32,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
body: String,
author: Profile,
}
impl Comment {
/// Create a comment from DB entities with author.following populated based on the leaders
fn with_leaders(
entities: (CommentEntity, ProfileEntity),
leader_ids: &HashSet<EntityId>,
) -> Self {
let is_following = leader_ids.contains(&entities.0.author_id);
let mut comment = Comment::from(entities);
comment.author.following = is_following;
comment
}
}
impl From<(CommentEntity, Profile)> for Comment {
fn from(data: (CommentEntity, Profile)) -> Self {
let CommentEntity {
comment_id,
body,
created_at,
updated_at,
..
} = data.0;
let author = data.1;
Comment {
id: comment_id as _,
created_at,
updated_at,
body,
author,
}
}
}
impl From<(CommentEntity, ProfileEntity)> for Comment {
fn from(entities: (CommentEntity, ProfileEntity)) -> Self {
Comment::from((entities.0, Profile::from(entities.1)))
}
}
#[derive(Serialize)]
struct CommentResponseBody {
comment: Comment,
}
#[derive(Serialize)]
struct MultipleCommentsResponseBody {
comments: Vec<Comment>,
}
/// Retrieve all articles
///
/// [List Articles](https://github.com/gothinkster/realworld/tree/master/api#list-articles)
pub async fn list_articles(
req: Request<impl Db<Conn = PoolConnection<impl Connect + ProvideData>>>,
) -> Response {
async move {
let state = req.state();
let mut tx = state
.conn()
.and_then(Connection::begin)
.await
.server_err()?;
let authenticated = optionally_auth(&req).transpose()?;
let entities = tx.get_all_articles().await?;
let leader_ids: HashSet<EntityId> = if let Some((user_id, _)) = authenticated {
HashSet::from_iter(tx.get_following(user_id).await?)
} else {
HashSet::default()
};
let articles = entities
.into_iter()
.map(|ents| Article::with_following(ents, &leader_ids))
.collect::<Vec<_>>();
tx.commit().await.server_err()?;
let resp = Response::new(200)
.body_json(&MultArticlesResponseBody::from(articles))
.server_err()?;
Ok::<_, Error>(resp)
}
.await
.unwrap_or_else(IntoResponse::into_response)
}
/// Get Article
///
/// https://github.com/gothinkster/realworld/tree/master/api#get-article
pub async fn get_article(req: Request<impl ProvideArticle>) -> Response {
unimplemented!()
pub async fn get_article(
req: Request<impl Db<Conn = PoolConnection<impl Connect + ProvideData>>>,
) -> Response {
async move {
let authenticated = optionally_auth(&req).transpose()?;
let state = req.state();
let mut tx = state
.conn()
.and_then(Connection::begin)
.await
.server_err()?;
let slug = req.param::<String>("slug").client_err()?;
let article = tx.get_article_by_slug(&slug).await?;
let profile_entity = tx.get_profile_by_id(article.author_id).await?;
let profile = if let Some((user_id, _)) = authenticated {
let following = tx.is_following(profile_entity.user_id, user_id).await?;
Profile::from(profile_entity).following(following)
} else {
Profile::from(profile_entity)
};
tx.commit().await.server_err()?;
let resp = to_json_response(&ArticleResponseBody {
article: Article::from((article, profile)),
})?;
Ok::<_, Error>(resp)
}
.await
.unwrap_or_else(IntoResponse::into_response)
}
/// Create Article
///
/// https://github.com/gothinkster/realworld/tree/master/api#create-article
pub async fn create_article(req: Request<impl ProvideArticle>) -> Response {
unimplemented!()
pub async fn create_article(
mut req: Request<impl Db<Conn = PoolConnection<impl Connect + ProvideData>>>,
) -> Response {
async move {
#[derive(Deserialize)]
struct ArticleRequestBody {
article: NewArticle,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct NewArticle {
title: String,
description: String,
body: String,
tag_list: Option<Vec<String>>,
}
let (user_id, _) = extract_and_validate_token(&req)?;
let body: ArticleRequestBody = req.body_json().await.client_err()?;
let slug = body.article.title.to_kebab_case();
debug!(
"Generated slug `{}` from title `{}`",
slug, body.article.title
);
let state = req.state();
let mut tx = state
.conn()
.and_then(Connection::begin)
.await
.server_err()?;
let (article, profile) = {
let ArticleRequestBody {
article:
NewArticle {
title,
description,
body,
tag_list,
},
} = body;
let profile = tx.get_profile_by_id(user_id).await?;
let article = tx
.create_article(user_id, &title, &slug, &description, &body)
.await?;
if let Some(tags) = tag_list.as_ref() {
tx.create_tags_for_article(article.article_id, tags.as_slice())
.await?
}
(article, profile)
};
tx.commit().await.server_err()?;
let resp = to_json_response(&ArticleResponseBody {
article: Article::from((article, profile)),
})?;
Ok::<_, Error>(resp)
}
.await
.unwrap_or_else(IntoResponse::into_response)
}
/// 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!()
pub async fn delete_article(
req: Request<impl Db<Conn = PoolConnection<impl Connect + ProvideData>>>,
) -> Response {
async move {
let (user_id, _) = extract_and_validate_token(&req)?;
let slug = req.param::<String>("slug").client_err()?;
let state = req.state();
let mut tx = state
.conn()
.and_then(Connection::begin)
.await.server_err()?;
let article = tx.get_article_by_slug(&slug).await?;
if article.author_id != user_id {
Err(Response::new(403))?
}
tx.delete_article(&slug).await?;
tx.commit().await.server_err()?;
Ok::<_, Error>(Response::new(200))
}
.await
.unwrap_or_else(IntoResponse::into_response)
}
/// Update the title, description, and/or body of an Article
///
/// [Update Article](https://github.com/gothinkster/realworld/tree/master/api#update-article)
pub async fn update_article(
mut req: Request<impl Db<Conn = PoolConnection<impl Connect + ProvideData>>>,
) -> Response {
async move {
#[derive(Deserialize)]
struct UpdateArticleBody {
article: ArticleUpdate,
}
#[derive(Deserialize)]
struct ArticleUpdate {
title: Option<String>,
description: Option<String>,
body: Option<String>,
}
let (user_id, _) = extract_and_validate_token(&req)?;
let slug = req.param::<String>("slug").client_err()?;
let body: UpdateArticleBody = req.body_json().await.client_err()?;
let state = req.state();
let mut tx = state
.conn()
.and_then(Connection::begin)
.await
.server_err()?;
let existing = tx.get_article_by_slug(&slug).await?;
if existing.author_id != user_id {
Err(Response::new(403))?
}
let author = tx.get_profile_by_id(user_id).await?;
let updates = {
let UpdateArticleBody {
article:
ArticleUpdate {
title,
description,
body,
},
} = body;
let new_slug = title
.as_ref()
.map_or_else(|| slug, |new_title| new_title.to_kebab_case());
ArticleEntity {
title: title.unwrap_or(existing.title),
slug: new_slug,
description: description.unwrap_or(existing.description),
body: body.unwrap_or(existing.body),
..existing
}
};
let updated = tx.update_article(&updates).await?;
let favorites_count = tx.get_favorites_count(&updates.slug).await?;
tx.commit().await.server_err()?;
let resp = to_json_response(&ArticleResponseBody {
article: Article::from((updated, author)).favorites_count(favorites_count),
})?;
Ok::<_, Error>(resp)
}
.await
.unwrap_or_else(IntoResponse::into_response)
}
/// Add a comment to an an article
///
/// [Add Comments to an Article](https://github.com/gothinkster/realworld/tree/master/api#add-comments-to-an-article)
pub async fn add_comment(
mut req: Request<impl Db<Conn = PoolConnection<impl Connect + ProvideData>>>,
) -> Response {
async move {
#[derive(Deserialize)]
struct CommentRequestBody {
comment: NewComment,
}
#[derive(Deserialize)]
struct NewComment {
body: String,
}
let (user_id, _) = extract_and_validate_token(&req)?;
let slug = req.param::<String>("slug").client_err()?;
let req_body: CommentRequestBody = req.body_json().await.client_err()?;
let state = req.state();
let mut tx = state
.conn()
.and_then(Connection::begin)
.await
.server_err()?;
let _article = tx.get_article_by_slug(&slug).await?;
let comment_ent = tx
.create_comment(&slug, user_id, &req_body.comment.body)
.await?;
let profile = tx.get_profile_by_id(user_id).await.map(Profile::from)?;
tx.commit().await.server_err()?;
let resp_body = CommentResponseBody {
comment: Comment::from((comment_ent, profile)),
};
let resp = to_json_response(&resp_body)?;
Ok::<_, Error>(resp)
}
.await
.unwrap_or_else(IntoResponse::into_response)
}
/// Get the comments placed on an article
///
/// [Get Comments from an Article](https://github.com/gothinkster/realworld/tree/master/api#get-comments-from-an-article)
pub async fn get_comments(
req: Request<impl Db<Conn = PoolConnection<impl Connect + ProvideData>>>,
) -> Response {
async move {
let authenticated = optionally_auth(&req).transpose()?;
let slug = req.param::<String>("slug").client_err()?;
let state = req.state();
let mut db = state
.conn()
.and_then(Connection::begin)
.await
.server_err()?;
let leader_ids: HashSet<EntityId> = if let Some((user_id, _)) = authenticated {
HashSet::from_iter(db.get_following(user_id).await?)
} else {
HashSet::default()
};
let comment_profile_pairs = db.get_comments_on_article(&slug).await?;
let comments = comment_profile_pairs
.into_iter()
.map(|ents| Comment::with_leaders(ents, &leader_ids))
.collect::<Vec<_>>();
let resp = to_json_response(&MultipleCommentsResponseBody { comments })?;
Ok::<_, Error>(resp)
}
.await
.unwrap_or_else(IntoResponse::into_response)
}
pub async fn delete_comment(
req: Request<impl Db<Conn = PoolConnection<impl Connect + ProvideData>>>,
) -> Response {
async move {
let (user_id, _) = extract_and_validate_token(&req)?;
let slug = req.param::<String>("slug").client_err()?;
let comment_id = req.param::<EntityId>("comment_id").client_err()?;
let state = req.state();
let mut db = state
.conn()
.and_then(Connection::begin)
.await
.server_err()?;
let comment = db.get_comment(&slug, comment_id).await?;
if comment.author_id != user_id {
Err(Response::new(403))?
}
db.delete_comment(&slug, comment_id).await?;
db.commit().await.server_err()?;
Ok::<_, Error>(Response::new(200))
}
.await
.unwrap_or_else(IntoResponse::into_response)
}
/// Favorite Article
///
/// https://github.com/gothinkster/realworld/tree/master/api#favorite-article
pub async fn favorite_article(
req: Request<impl Db<Conn = PoolConnection<impl Connect + ProvideData>>>,
) -> Response {
should_favorite(req, true)
.await
.unwrap_or_else(IntoResponse::into_response)
}
/// Unfavorite Article
///
/// https://github.com/gothinkster/realworld/tree/master/api#favorite-article
pub async fn unfavorite_article(
req: Request<impl Db<Conn = PoolConnection<impl Connect + ProvideData>>>,
) -> Response {
should_favorite(req, false)
.await
.unwrap_or_else(IntoResponse::into_response)
}
async fn should_favorite(
req: Request<impl Db<Conn = PoolConnection<impl Connect + ProvideData>>>,
should_favorite: bool,
) -> tide::Result<Response> {
let (user_id, _) = extract_and_validate_token(&req)?;
let slug = req.param::<String>("slug").client_err()?;
let state = req.state();
let mut tx = state
.conn()
.and_then(Connection::begin)
.await
.server_err()?;
match should_favorite {
true => tx.create_favorite(user_id, &slug),
false => tx.delete_favorite(user_id, &slug),
}
.await?;
let article = tx.get_article_by_slug(&slug).await?;
let author = tx.get_profile_by_id(article.author_id).await?;
let favorites_count = tx.get_favorites_count(&slug).await?;
tx.commit().await.server_err()?;
let resp = to_json_response(&ArticleResponseBody {
article: Article {
favorited: should_favorite,
favorites_count,
..From::from((article, author))
},
})?;
Ok(resp)
}
/// Feed Articles
///
/// https://github.com/gothinkster/realworld/tree/master/api#feed-articles
pub async fn get_feed(
req: Request<impl Db<Conn = PoolConnection<impl Connect + ProvideData>>>,
) -> Response {
async move {
let (user_id, _) = extract_and_validate_token(&req)?;
let state = req.state();
let mut db = state.conn().await.server_err()?;
let leader_ids = db
.get_following(user_id)
.await?
.into_iter()
.collect::<HashSet<_>>();
let articles = db
.get_all_articles()
.await?
.into_iter()
.filter(|(article, _)| leader_ids.contains(&article.author_id))
.map(|(article, profile)| (article, Profile::from(profile).following(true)))
.collect::<Vec<_>>();
let resp = to_json_response(&MultArticlesResponseBody {
articles: vec![],
articles_count: articles.len(),
})?;
Ok::<_, Error>(resp)
}
.await
.unwrap_or_else(IntoResponse::into_response)
}
/// Get Tags
///
/// https://github.com/gothinkster/realworld/tree/master/api#get-tags
pub async fn get_tags(
req: Request<impl Db<Conn = PoolConnection<impl Connect + ProvideData>>>,
) -> Response {
async move {
let state = req.state();
let mut db = state.conn().await.server_err()?;
let tags = db.get_tags().await?;
#[derive(Serialize)]
struct GetTagsResponse {
tags: Vec<String>,
}
Ok::<_, Error>(to_json_response(&GetTagsResponse { tags })?)
}
.await
.unwrap_or_else(IntoResponse::into_response)
}

View file

@ -1,36 +1,17 @@
use log::*;
use tide::{IntoResponse, Response};
/// Route handlers for the /api/articles APIs
pub mod articles;
/// Models for objects returned by the web API
///
/// See the [API Spec](https://github.com/gothinkster/realworld/tree/master/api#json-objects-returned-by-api)
/// for more information.
pub mod model;
/// Route handlers for the /profiles API
pub mod profiles;
/// 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)
}
}
/// Utility functions and traits
pub mod util;

View file

@ -0,0 +1,113 @@
use std::collections::HashSet;
use serde::Serialize;
use chrono::{DateTime, Utc};
use crate::db::model::*;
/// An article
///
/// [API Spec](https://github.com/gothinkster/realworld/tree/master/api#single-article)
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub(in crate::api) struct Article {
pub title: String,
pub slug: String,
pub description: String,
pub body: String,
pub author: Profile,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub tag_list: Vec<String>,
pub favorited: bool,
pub favorites_count: usize,
}
impl Article {
/// Create an article with the author.following field populated
pub fn with_following(entities: (ArticleEntity, ProfileEntity), leader_ids: &HashSet<EntityId>) -> Self {
let is_following = leader_ids.contains(&entities.1.user_id);
let mut article = Article::from(entities);
article.author.following = is_following;
article
}
/// Set the favorites_count
pub fn favorites_count(self, favorites_count: usize) -> Self {
Article {
favorites_count,
..self
}
}
}
impl From<(ArticleEntity, ProfileEntity)> for Article {
fn from(entities: (ArticleEntity, ProfileEntity)) -> Self {
let article = entities.0;
let author = Profile::from(entities.1);
(article, author).into()
}
}
impl From<(ArticleEntity, Profile)> for Article {
fn from(entities: (ArticleEntity, Profile)) -> Self {
let ArticleEntity {
title,
slug,
description,
body,
created_at,
updated_at,
..
} = entities.0;
let author = entities.1;
Article {
title,
slug,
description,
body,
author,
created_at,
updated_at,
tag_list: vec![],
favorited: false,
favorites_count: 0
}
}
}
/// A profile for a User
///
/// [API Spec](https://github.com/gothinkster/realworld/tree/master/api#profile)
#[derive(Default, serde::Serialize)]
pub(in crate::api) struct Profile {
pub username: String,
pub bio: Option<String>,
pub image: Option<String>,
pub following: bool,
}
impl Profile {
pub fn following(self, following: bool) -> Self {
Profile {
following,
..self
}
}
}
impl From<ProfileEntity> for Profile {
fn from(ent: ProfileEntity) -> Self {
let ProfileEntity {
username, bio, image, ..
} = ent;
Profile {
username,
bio,
image,
following: false
}
}
}

View file

@ -0,0 +1,120 @@
use log::*;
use serde::Serialize;
use sqlx::pool::PoolConnection;
use sqlx::{Connect, Connection};
use tide::{Error, IntoResponse, Request, Response, ResultExt};
use futures::TryFutureExt;
use crate::api::util::*;
use crate::db::model::*;
use crate::db::Db;
use crate::api::model::*;
#[derive(Serialize)]
struct ProfileResponseBody {
profile: Profile,
}
impl From<Profile> for ProfileResponseBody {
fn from(profile: Profile) -> Self {
ProfileResponseBody { profile }
}
}
/// Retrieve a profile by username
///
/// [Get Profile](https://github.com/gothinkster/realworld/tree/master/api#get-profile)
pub async fn get_profile(
req: Request<impl Db<Conn = PoolConnection<impl Connect + ProvideData>>>,
) -> Response {
async move {
let authenticated = optionally_auth(&req).transpose()?;
let leader_username = req.param::<String>("username").client_err()?;
debug!("Searching for profile {}", leader_username);
let state = req.state();
let mut tx = state
.conn()
.and_then(Connection::begin)
.await
.server_err()?;
let leader = tx.get_profile_by_username(&leader_username).await?;
debug!("Found profile for {}", leader_username);
let is_following = if let Some((follower_id, _)) = authenticated {
tx.is_following(leader.user_id, follower_id).await?
} else {
false
};
tx.commit().await.server_err()?;
let resp = to_json_response(&ProfileResponseBody {
profile: Profile::from(leader).following(is_following),
})?;
Ok::<_, Error>(resp)
}
.await
.unwrap_or_else(IntoResponse::into_response)
}
/// Follow a user
///
/// [Follow User](https://github.com/gothinkster/realworld/tree/master/api#follow-user)
pub async fn follow_user(
req: Request<impl Db<Conn = PoolConnection<impl Connect + ProvideData>>>,
) -> Response {
should_follow(req, true)
.await
.unwrap_or_else(IntoResponse::into_response)
}
/// Stop following a user
///
/// [Unfollow User](https://github.com/gothinkster/realworld/tree/master/api#unfollow-user)
pub async fn unfollow_user(
req: Request<impl Db<Conn = PoolConnection<impl Connect + ProvideData>>>,
) -> Response {
should_follow(req, false)
.await
.unwrap_or_else(IntoResponse::into_response)
}
/// Adds or removes a following relationship
async fn should_follow(
req: Request<impl Db<Conn = PoolConnection<impl Connect + ProvideData>>>,
should_follow: bool,
) -> tide::Result<Response> {
let (user_id, _) = extract_and_validate_token(&req)?;
let leader_username = req.param::<String>("username").client_err()?;
let state = req.state();
let mut tx = state
.conn()
.and_then(Connection::begin)
.await
.server_err()?;
let leader_ent = tx.get_profile_by_username(&leader_username).await?;
match should_follow {
true => {
debug!("User {} will now follow {}", user_id, leader_username);
tx.add_follower(&leader_username, user_id).await
}
false => {
debug!("User {} will no longer follow {}", user_id, leader_username);
tx.delete_follower(&leader_username, user_id).await
}
}?;
tx.commit().await.server_err()?;
let profile = Profile::from(leader_ent).following(should_follow);
let resp = to_json_response(&ProfileResponseBody::from(profile))?;
Ok(resp)
}

View file

@ -1,17 +1,22 @@
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";
use chrono::{Duration, Utc};
use futures::TryFutureExt;
use log::*;
use rand::{thread_rng, RngCore};
use serde::{Deserialize, Serialize, Deserializer};
use sqlx::pool::PoolConnection;
use sqlx::{Connect, Connection};
use tide::{Error, IntoResponse, Request, Response, ResultExt};
// User
// https://github.com/gothinkster/realworld/tree/master/api#users-for-authentication
#[derive(Default, serde::Serialize)]
use crate::api::util::{extract_and_validate_token, to_json_response, TokenClaims, SECRET_KEY};
use crate::db::model::{ProvideAuthn, UserEntity};
use crate::db::Db;
/// A User
///
/// [User](https://github.com/gothinkster/realworld/tree/master/api#users-for-authentication)
#[derive(Default, Serialize)]
pub struct User {
pub email: String,
pub token: Option<String>,
@ -20,34 +25,138 @@ pub struct User {
pub image: Option<String>,
}
// Registration
// https://github.com/gothinkster/realworld/tree/master/api#registration
impl User {
fn token(mut self, token: Option<String>) -> Self {
self.token = token;
self
}
}
// #[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
/// A field wherein null is significant
///
/// The `realworld` API Spec allows for certain fields to be explicitly set to null
/// (e.g. `image` on [User] objects).
///
/// Serde treats missing values and null values as the same so this type is used to capture
/// that null has meaning. Note that Option<Option<T>> can also be used, however this is slightly
/// more expressive
enum Nullable<T> {
Data(T),
Null,
Missing,
}
impl<T> Nullable<T> {
/// Converts the field to option if populated or returns `optb`
///
/// Based on [Option::or].
fn or(self, optb: Option<T>) -> Option<T> {
match self {
Nullable::Data(d) => Some(d),
Nullable::Null => None,
Nullable::Missing => optb,
}
#[derive(serde::Deserialize)]
}
}
impl<T> From<Option<T>> for Nullable<T> {
fn from(opt: Option<T>) -> Self {
if let Some(data) = opt {
Nullable::Data(data)
} else {
Nullable::Null
}
}
}
impl<'de, T> Deserialize<'de> for Nullable<T>
where T: Deserialize<'de>
{
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> where
D: Deserializer<'de>
{
Option::deserialize(deserializer).map(Nullable::from)
}
}
impl<T> Default for Nullable<T> {
fn default() -> Self {
Nullable::Missing
}
}
/// The response body for User API requests
#[derive(Serialize)]
struct UserResponseBody {
user: User,
}
impl From<User> for UserResponseBody {
fn from(user: User) -> Self {
UserResponseBody { user }
}
}
impl From<UserEntity> for User {
fn from(entity: UserEntity) -> Self {
let UserEntity {
email,
username,
bio,
image,
..
} = entity;
User {
email,
token: None,
username,
bio,
image,
}
}
}
/// Register a new user
///
/// [Registration](https://github.com/gothinkster/realworld/tree/master/api#registration)
pub async fn register(
mut req: Request<impl Db<Conn = PoolConnection<impl Connect + ProvideAuthn>>>,
) -> Response {
async {
#[derive(Deserialize)]
struct RegisterRequestBody {
user: NewUser,
}
#[derive(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())))?;
// n.b. we don't use req.body_json() because it swallows serde's useful error messages
let body = req.body_bytes().await.server_err()?;
let hashed_password = hash_password(&password)?;
let RegisterRequestBody {
user:
NewUser {
username,
email,
password,
},
} = serde_json::from_slice(&body)
.map_err(|e| Response::new(400).body_string(e.to_string()))?;
let hashed_password = hash_password(&password).server_err()?;
let state = req.state();
let mut db = state.conn().await.server_err()?;
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
// n.b. token creation is a soft-failure as the user can try logging in separately
let token = generate_token(id)
.map_err(|e| {
warn!("Failed to create auth token -- {}", e);
@ -55,191 +164,189 @@ pub async fn register(req: Request<impl ProvideUser>) -> Response {
})
.ok();
#[derive(serde::Serialize)]
struct RegisterResponseBody {
user: User,
}
let user = User {
email,
token,
username,
bio: None,
image: None,
};
let resp = to_json_response(&UserResponseBody::from(user))?;
let resp = Response::new(200)
.body_json(&RegisterResponseBody {
user: User {
email,
token,
username,
..Default::default()
}
})
.map_err(anyhow::Error::from)?;
Ok(resp)
Ok::<_, Error>(resp)
}
inner(req).await.unwrap_or_else(IntoResponse::into_response)
.await
.unwrap_or_else(IntoResponse::into_response)
}
// Get Current User
// https://github.com/gothinkster/realworld/tree/master/api#get-current-user
/// Get the current user based on their authorization
///
/// [Get Current User](https://github.com/gothinkster/realworld/tree/master/api#get-current-user)
pub async fn get_current_user(
req: Request<impl Db<Conn = PoolConnection<impl Connect + ProvideAuthn>>>,
) -> Response {
async move {
let (user_id, token) = extract_and_validate_token(&req)?;
// #[get("/api/user")]
pub async fn get_current_user(req: Request<impl ProvideUser>) -> Response {
async fn inner(req: Request<impl ProvideUser>) -> ApiResult<Response> {
let state = req.state();
let mut db = state.conn().await.server_err()?;
// 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()))
})?;
// n.b - the app doesn't support deleting users
let user_ent= db.get_user_by_id(user_id).await?;
let token = get_token_from_request(auth_header);
let resp = to_json_response(&UserResponseBody::from(User::from(user_ent).token(Some(token))))?;
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)
Ok::<_, Error>(resp)
}
inner(req).await.unwrap_or_else(IntoResponse::into_response)
.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)]
/// Login to Conduit
///
/// [Login](https://github.com/gothinkster/realworld/tree/master/api#authentication)
pub async fn login(
mut req: Request<impl Db<Conn = PoolConnection<impl Connect + ProvideAuthn>>>,
) -> Response {
async move {
#[derive(Deserialize)]
struct LoginRequestBody {
user: Creds
user: Creds,
}
#[derive(serde::Deserialize)]
#[derive(Deserialize)]
struct Creds {
email: String,
password: String,
}
let LoginRequestBody {user: Creds { email, password }} = req.
body_json()
.await
.map_err(|_| Response::new(400))?;
let LoginRequestBody {
user: Creds { email, password },
} = req.body_json().await.client_err()?;
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
})?;
let state = req.state();
let mut db = state.conn().await.server_err()?;
debug!("User {} matches email {}", user.id, &email);
let user_ent = db.get_user_by_email(&email).await.map_err(|e| {
error!("Failed to get user -- {}", e);
Response::from(e).set_status(http::StatusCode::FORBIDDEN)
})?;
let hashed_password = user.password.as_ref()
.ok_or_else(|| Response::new(403))?;
debug!("User {} matches email {}", user_ent.user_id, &email);
debug!("Authenticating user {}", user.id);
let valid = argon2::verify_encoded(hashed_password, &password.as_bytes())
.map_err(|_| Response::new(403))?;
let hashed_password = user_ent.password.as_str();
if ! valid {
debug!("User {} failed authentication", user.id);
debug!("Authenticating user {}", user_ent.user_id);
let valid =
argon2::verify_encoded(hashed_password, &password.as_bytes()).with_err_status(403)?;
if !valid {
debug!("User {} failed authentication", user_ent.user_id);
Err(Response::new(403))?
}
debug!("Successfully authenticated {}, generating auth token", user.id);
let token = generate_token(user.id)?;
debug!(
"Successfully authenticated {}, generating auth token",
user_ent.user_id
);
let token = generate_token(user_ent.user_id).server_err()?;
#[derive(serde::Serialize)]
struct LoginResponseBody {
user: User
let user = User {
token: Some(token),
..user_ent.into()
};
let resp = to_json_response(&UserResponseBody::from(user))?;
Ok::<_, tide::Error>(resp)
}
.await
.unwrap_or_else(IntoResponse::into_response)
}
/// Update a user's email, bio, or image
///
/// [Update User](https://github.com/gothinkster/realworld/tree/master/api#update-user)
pub async fn update_user(
mut req: Request<impl Db<Conn = PoolConnection<impl Connect + ProvideAuthn>>>,
) -> Response {
async move {
#[derive(Deserialize)]
struct UpdateRequestBody {
user: UserUpdate,
}
let resp = to_json_response(&LoginResponseBody {
user: User {
email,
token: Some(token),
username: user.username,
..Default::default()
#[derive(Deserialize)]
struct UserUpdate {
email: Option<String>,
#[serde(default)]
bio: Nullable<String>,
#[serde(default)]
image: Nullable<String>,
}
let (user_id, _) = extract_and_validate_token(&req)?;
let body = req.body_json().await.server_err()?;
let state = req.state();
let mut tx = state
.conn()
.and_then(Connection::begin)
.await
.server_err()?;
let updated = {
let UpdateRequestBody {
user: UserUpdate { email, bio, image },
} = body;
let existing = tx.get_user_by_id(user_id).await?;
UserEntity {
email: email.unwrap_or(existing.email),
bio: bio.or(existing.bio),
image: image.or(existing.image),
..existing
}
})?;
Ok(resp)
};
debug!("Updating user {}", user_id);
tx.update_user(&updated).await?;
debug!(
"Successfully updated user {}. Committing Transaction.",
user_id
);
tx.commit().await.server_err()?;
let resp = to_json_response(&UserResponseBody::from(User::from(updated)))?;
Ok::<_, Error>(resp)
}
inner(req).await.unwrap_or_else(IntoResponse::into_response)
.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> {
/// Hashes and salts a password for storage in a DB
fn hash_password(password: &str) -> argon2::Result<String> {
let salt = generate_random_salt();
let hash = argon2::hash_encoded(password.as_bytes(), &salt, &argon2::Config::default())?;
Ok(hash)
}
/// Generate a salt that will be used on passwords
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> {
/// Generate a JWT for the user_id
fn generate_token(user_id: i32) -> jsonwebtoken::errors::Result<String> {
use jsonwebtoken::Header;
let exp = Utc::now() + Duration::hours(1);
let exp = Utc::now() + Duration::hours(24); // n.b. (bad for sec, good for testing)
let token = jsonwebtoken::encode(
&Header::default(),
&TokenClaims {

View file

@ -0,0 +1,102 @@
use log::*;
use tide::{Request, Response};
use crate::db::model::ProvideError;
/// The signing key used to mint auth tokens
pub const SECRET_KEY: &str = "this-is-the-most-secret-key-ever-secreted";
#[derive(serde::Serialize, serde::Deserialize)]
pub struct TokenClaims {
pub sub: i32,
pub exp: i64,
}
/// Retrieve the authorization header from a Request
fn get_auth_header<T>(req: &Request<T>) -> Option<&str> {
// TODO: It is possible the user will provide multiple auth headers, we should try all of them
req.header("Authorization")
}
/// Extract the JWT token from a header string
fn parse_token(header: &str) -> String {
header.splitn(2, ' ').nth(1).unwrap_or_default().to_owned()
}
/// Authorize a JWT returning the user_id
fn authorize_token(token: &str) -> jsonwebtoken::errors::Result<i32> {
let data = jsonwebtoken::decode::<TokenClaims>(
token,
SECRET_KEY.as_ref(),
&jsonwebtoken::Validation::default(),
)?;
Ok(data.claims.sub)
}
/// Validate an auth token if one is present in the request
///
/// This is useful for routes where auth is optional (e.g. /api/get/articles
///
/// 1. No authorization header present -> None
/// 2. Invalid authorization header -> Some(Error)
/// 3. Valid authorization header -> Some(Ok)
pub fn optionally_auth<T>(req: &Request<T>) -> Option<tide::Result<(i32, String)>> {
if req.headers().contains_key("Authorization") {
Some(extract_and_validate_token(req))
} else {
None
}
}
/// Validates an auth token from a Request, returning the user ID and token if successful
pub fn extract_and_validate_token<T>(req: &Request<T>) -> tide::Result<(i32, String)> {
debug!("Checking for auth header");
let auth_header = get_auth_header(&req)
.ok_or_else(|| Response::new(400).body_string("Missing Authorization header".to_owned()))?;
debug!("Extracting token from auth header");
let token = parse_token(auth_header);
debug!("Authorizing token");
let user_id =
authorize_token(&token).map_err(|e| Response::new(403).body_string(format!("{}", e)))?;
debug!("Token is valid and belongs to user {}", user_id);
Ok((user_id, token))
}
/// Converts a serializable payload into a JSON response
///
/// If the body cannot be serialized an Err(Response) will be returned with the serialization error
pub 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)
})
}
impl From<ProvideError> for Response {
/// Convert a ProvideError into a [tide::Response]
///
/// This allows the usage of
fn from(e: ProvideError) -> Response {
match e {
ProvideError::NotFound => Response::new(404),
ProvideError::Provider(e) => Response::new(500).body_string(e.to_string()),
ProvideError::UniqueViolation(details) => Response::new(409).body_string(details),
ProvideError::ModelViolation(details) => Response::new(400).body_string(details),
}
}
}
impl From<ProvideError> for tide::Error {
/// Convert a ProvideError into a [tide::Error] via [Response::from]
///
/// This allows the use of the `?` operator in handler functions
fn from(e: ProvideError) -> Self {
Response::from(e).into()
}
}

View file

@ -1,10 +1,43 @@
use async_trait::async_trait;
/// Database implementation for PostgreSQL
#[cfg(feature = "postgres")]
pub mod pg;
/// Database implementation for SQLite
///
/// The implementation of the handler functions is a bit more complex than Postgres
/// as sqlite (1) does not support nested transactions and (2) does not support the RETURNING
/// clause.
#[cfg(feature = "sqlite")]
pub mod sqlite;
/// Database models
pub mod model;
/// A type that abstracts a database
#[async_trait]
pub trait Db {
/// A connection to the database
type Conn;
/// Establish a connection with the database
async fn conn(&self) -> sqlx::Result<Self::Conn>;
}
/// Create a batch insert statement
///
/// This incantation borrowed from @mehcode
/// https://discordapp.com/channels/665528275556106240/665528275556106243/694835667401703444
fn build_batch_insert(rows: usize, columns: usize) -> String {
use itertools::Itertools;
(0..rows)
.format_with(",", |i, f| {
f(&format_args!(
"({})",
(1..=columns).format_with(",", |j, f| f(&format_args!("${}", j + (i * columns))))
))
})
.to_string()
}

View file

@ -1,35 +1,214 @@
use async_trait::async_trait;
use std::convert::TryFrom;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::Error as SqlxError;
pub type EntityId = i32;
/// A user that is registered with the application
///
/// This entity is used to for authN/authZ
pub struct UserEntity {
pub id: i32,
pub user_id: EntityId,
pub email: String,
pub username: String,
pub password: Option<String>, // FIXME(RFC): Why is this nullable in the DB?
pub password: String,
pub bio: Option<String>,
pub image: Option<String>,
}
/// A type that can provide [`UserEntities`]
/// A type that can provide stable storage for user authentication
///
/// This provider is used for managing users and passwords
#[async_trait]
pub trait ProvideUser {
async fn create_user(&self, username: &str, email: &str, password: &str)
-> anyhow::Result<i32>;
pub trait ProvideAuthn {
async fn create_user(
&mut self,
username: &str,
email: &str,
password: &str,
) -> ProvideResult<EntityId>;
async fn get_user_by_id(&self, user_id: i32) -> anyhow::Result<UserEntity>;
async fn get_user_by_id(&mut self, user_id: EntityId) -> ProvideResult<UserEntity>;
async fn get_user_by_email(&self, email: &str) -> anyhow::Result<UserEntity>;
async fn get_user_by_email(&mut self, email: &str) -> ProvideResult<UserEntity>;
async fn update_user(&mut self, updated: &UserEntity) -> ProvideResult<()>;
}
/// A profile for an author of an article or comment
///
/// These should map 1:1 with users
#[derive(Default)]
pub struct ProfileEntity {
pub user_id: EntityId,
pub username: String,
pub bio: Option<String>,
pub image: Option<String>,
}
/// An artifact authored by a user
pub struct ArticleEntity {
pub article_id: EntityId,
pub title: String,
pub slug: String,
pub description: String,
pub body: String,
pub tag_list: Vec<String>,
pub author_id: EntityId,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// A comment on an article
pub struct CommentEntity {
pub comment_id: EntityId,
pub body: String,
pub article_id: EntityId,
pub author_id: EntityId,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// A type that provides access to stable storage for application data
#[async_trait]
pub trait ProvideArticle {
async fn create_article(&self) -> anyhow::Result<ArticleEntity>;
pub trait ProvideData {
async fn create_article(
&mut self,
author_id: EntityId,
title: &str,
slug: &str,
description: &str,
body: &str,
) -> ProvideResult<ArticleEntity>;
async fn update_article(&self) -> anyhow::Result<ArticleEntity>;
async fn create_tags_for_article(
&mut self,
article_id: EntityId,
tags: &'async_trait [impl AsRef<str> + Send + Sync],
) -> ProvideResult<()>;
async fn delete_article(&self) -> anyhow::Result<ArticleEntity>;
async fn update_article(&mut self, updated: &ArticleEntity) -> ProvideResult<ArticleEntity>;
async fn delete_article(&mut self, slug: &str) -> ProvideResult<()>;
async fn get_article_by_slug(&mut self, slug: &str) -> ProvideResult<ArticleEntity>;
/// Retrieve all articles and authors
async fn get_all_articles(&mut self) -> ProvideResult<Vec<(ArticleEntity, ProfileEntity)>>;
async fn get_favorites_count(&mut self, article_slug: &str) -> ProvideResult<usize>;
async fn create_favorite(&mut self, user_id: EntityId, article_slug: &str)
-> ProvideResult<()>;
async fn delete_favorite(&mut self, user_id: EntityId, article_slug: &str)
-> ProvideResult<()>;
async fn get_tags(&mut self) -> ProvideResult<Vec<String>>;
async fn create_comment(
&mut self,
article_slug: &str,
author_id: EntityId,
body: &str,
) -> ProvideResult<CommentEntity>;
async fn delete_comment(
&mut self,
article_slug: &str,
comment_id: EntityId,
) -> ProvideResult<()>;
async fn get_comment(
&mut self,
article_slug: &str,
comment_id: EntityId,
) -> ProvideResult<CommentEntity>;
async fn get_comments_on_article(
&mut self,
article_slug: &str,
) -> ProvideResult<Vec<(CommentEntity, ProfileEntity)>>;
async fn get_profile_by_username(&mut self, username: &str) -> ProvideResult<ProfileEntity>;
async fn get_profile_by_id(&mut self, profile_id: EntityId) -> ProvideResult<ProfileEntity>;
async fn add_follower(
&mut self,
leader_username: &str,
follower_id: EntityId,
) -> ProvideResult<()>;
async fn delete_follower(
&mut self,
leader_username: &str,
follower_id: EntityId,
) -> ProvideResult<()>;
async fn is_following(
&mut self,
leader_id: EntityId,
follower_id: EntityId,
) -> ProvideResult<bool>;
/// Get users that are being followed by a user
async fn get_following(&mut self, follower_id: EntityId) -> ProvideResult<Vec<EntityId>>;
}
pub type ProvideResult<T> = Result<T, ProvideError>;
/// An error returned by a provider
#[derive(Debug, thiserror::Error)]
pub enum ProvideError {
/// The requested entity does not exist
#[error("Entity does not exist")]
NotFound,
/// The operation violates a uniqueness constraint
#[error("{0}")]
UniqueViolation(String),
/// The requested operation violates the data model
#[error("{0}")]
ModelViolation(String),
#[error(transparent)]
/// A generic unhandled error
Provider(sqlx::Error),
}
impl From<SqlxError> for ProvideError {
/// Convert a SQLx error into a provider error
///
/// For Database errors we attempt to downcast
///
/// FIXME(RFC): I have no idea if this is sane
fn from(e: SqlxError) -> Self {
log::debug!("sqlx returned err -- {:#?}", &e);
match e {
SqlxError::RowNotFound => ProvideError::NotFound,
SqlxError::Database(db_err) => {
#[cfg(feature = "postgres")]
{
if let Some(pg_err) = db_err.try_downcast_ref::<sqlx::postgres::PgError>() {
if let Ok(provide_err) = ProvideError::try_from(pg_err) {
return provide_err
}
}
}
#[cfg(feature = "sqlite")]
{
if let Some(sqlite_err) = db_err.try_downcast_ref::<sqlx::sqlite::SqliteError>() {
if let Ok(provide_err) = ProvideError::try_from(sqlite_err) {
return provide_err
}
}
}
ProvideError::Provider(SqlxError::Database(db_err))
}
_ => ProvideError::Provider(e),
}
}
}

View file

@ -1,44 +1,82 @@
use std::convert::TryFrom;
use async_trait::async_trait;
use sqlx::PgPool;
use sqlx::pool::PoolConnection;
use sqlx::{PgConnection, PgPool};
use sqlx::error::DatabaseError;
use sqlx::postgres::PgError;
use super::model::*;
use anyhow::Error;
use crate::db::model::*;
use crate::db::Db;
pub async fn connect(db_url: &str) -> anyhow::Result<PgPool> {
/// Open a connection to a database
pub async fn connect(db_url: &str) -> sqlx::Result<PgPool> {
let pool = PgPool::new(db_url).await?;
Ok(pool)
}
impl TryFrom<&PgError> for ProvideError {
type Error = ();
/// Attempt to convert a Postgres error into a generic ProvideError
///
/// Unexpected cases will be bounced back to the caller for handling
///
/// * [Postgres Error Codes](https://www.postgresql.org/docs/current/errcodes-appendix.html)
fn try_from(pg_err: &PgError) -> Result<Self, Self::Error> {
let provider_err = match pg_err.code().unwrap() {
"23505" => ProvideError::UniqueViolation(pg_err.details().unwrap().to_owned()),
code if code.starts_with("23") => {
ProvideError::ModelViolation(pg_err.message().to_owned())
}
_ => return Err(()),
};
Ok(provider_err)
}
}
#[async_trait]
impl ProvideUser for PgPool {
impl Db for PgPool {
type Conn = PoolConnection<PgConnection>;
async fn conn(&self) -> sqlx::Result<Self::Conn> {
self.acquire().await
}
}
#[async_trait]
impl ProvideAuthn for PgConnection {
async fn create_user(
&self,
&mut self,
username: &str,
email: &str,
password: &str,
) -> anyhow::Result<i32> {
let rec = sqlx::query!(
) -> ProvideResult<EntityId> {
let user_id = sqlx::query!(
r#"
INSERT INTO users ( username, email, password )
VALUES ( $1, $2, $3 )
RETURNING id
RETURNING user_id
"#,
username,
email,
password
)
.fetch_one(self)
.await?;
Ok(rec.id)
.await
.map(|rec| rec.user_id)?;
Ok(user_id)
}
async fn get_user_by_id(&self, user_id: i32) -> anyhow::Result<UserEntity> {
async fn get_user_by_id(&mut self, user_id: i32) -> ProvideResult<UserEntity> {
let rec = sqlx::query_as!(
UserEntity,
r#"
SELECT username, email, id, password
SELECT user_id, username, email, password, image, bio
FROM users
WHERE id = $1
WHERE user_id = $1
"#,
user_id
)
@ -48,11 +86,11 @@ WHERE id = $1
Ok(rec)
}
async fn get_user_by_email(&self, email: &str) -> anyhow::Result<UserEntity> {
async fn get_user_by_email(&mut self, email: &str) -> ProvideResult<UserEntity> {
let rec = sqlx::query_as!(
UserEntity,
r#"
SELECT username, email, id, password
SELECT user_id, username, email, password, image, bio
FROM users
WHERE email = $1
"#,
@ -63,19 +101,464 @@ WHERE email = $1
Ok(rec)
}
async fn update_user(&mut self, updated: &UserEntity) -> ProvideResult<()> {
sqlx::query!(
r#"
UPDATE users
SET email = $1, username = $2, password = $3, image = $4, bio = $5, updated_at = DEFAULT
WHERE user_id = $6
RETURNING user_id
"#,
updated.email,
updated.username,
updated.password,
updated.image,
updated.bio,
updated.user_id,
)
.fetch_one(self)
.await?;
Ok(())
}
}
#[async_trait]
impl ProvideArticle for PgPool {
async fn create_article(&self) -> Result<ArticleEntity, Error> {
unimplemented!()
impl ProvideData for PgConnection {
async fn create_article(
&mut self,
author_id: EntityId,
title: &str,
slug: &str,
description: &str,
body: &str,
) -> ProvideResult<ArticleEntity> {
let article = sqlx::query_as!(
ArticleEntity,
r#"
INSERT INTO articles ( title, slug, description, body, author_id )
VALUES ( $1, $2, $3, $4, $5)
RETURNING *
"#,
title,
slug,
description,
body,
author_id,
)
.fetch_one(self)
.await?;
Ok(article)
}
async fn update_article(&self) -> Result<ArticleEntity, Error> {
unimplemented!()
async fn create_tags_for_article(
&mut self,
article_id: EntityId,
tags: &'async_trait [impl AsRef<str> + Send + Sync],
) -> ProvideResult<()> {
let stmt = format!(
r#"
INSERT INTO TAGS (tag_name, article_id)
VALUES {}
"#,
super::build_batch_insert(tags.len(), 2)
);
tags.iter()
.fold(sqlx::query(&stmt), |q, tag_name| {
q.bind(tag_name.as_ref()).bind(article_id)
})
.execute(self)
.await?;
Ok(())
}
async fn delete_article(&self) -> Result<ArticleEntity, Error> {
unimplemented!()
async fn update_article(&mut self, updated: &ArticleEntity) -> ProvideResult<ArticleEntity> {
let rec = sqlx::query_as!(
ArticleEntity,
r#"
UPDATE articles
SET title = $2, slug = $3, description = $4, body = $5, updated_at = DEFAULT
WHERE article_id = $1
RETURNING *
"#,
updated.article_id,
updated.title,
updated.slug,
updated.description,
updated.body,
)
.fetch_one(self)
.await?;
Ok(rec)
}
async fn delete_article(&mut self, slug: &str) -> ProvideResult<()> {
sqlx::query!(
r#"
DELETE FROM articles
WHERE slug = $1
RETURNING article_id
"#,
slug
)
.fetch_one(self)
.await?;
Ok(())
}
async fn get_article_by_slug(&mut self, slug: &str) -> ProvideResult<ArticleEntity> {
let rec = sqlx::query_as!(
ArticleEntity,
r#"
SELECT *
FROM articles
WHERE slug = $1
"#,
slug
)
.fetch_one(self)
.await?;
Ok(rec)
}
async fn get_all_articles(&mut self) -> ProvideResult<Vec<(ArticleEntity, ProfileEntity)>> {
let recs = sqlx::query!(
r#"
SELECT
articles.*
,profiles.username, profiles.bio as bio, profiles.image
FROM articles
INNER JOIN profiles ON articles.author_id = profiles.user_id
ORDER BY created_at
"#
)
.fetch_all(self)
.await?;
let entities = recs
.into_iter()
.map(|rec| {
let article = ArticleEntity {
article_id: rec.article_id,
title: rec.title,
slug: rec.slug,
description: rec.description,
body: rec.body,
author_id: rec.author_id,
created_at: rec.created_at,
updated_at: rec.updated_at,
};
// FIXME(pg) for some reason query can't figure out the view columns are not nullable
let author = ProfileEntity {
user_id: rec.author_id,
username: rec.username.unwrap(),
bio: rec.bio,
image: rec.image,
};
(article, author)
})
.collect::<Vec<_>>();
Ok(entities)
}
async fn get_favorites_count(&mut self, article_slug: &str) -> ProvideResult<usize> {
let count = sqlx::query!(
r#"
SELECT COUNT(favs.user_id) as count
FROM favorite_articles AS favs
INNER JOIN articles ON articles.article_id = favs.article_id
WHERE articles.slug = $1
"#,
article_slug
)
.fetch_one(self)
.await
.map(|rec| rec.count.unwrap_or(0) as usize)?;
Ok(count)
}
async fn create_favorite(
&mut self,
user_id: EntityId,
article_slug: &str,
) -> ProvideResult<()> {
sqlx::query!(
r#"
INSERT INTO favorite_articles ( user_id, article_id )
VALUES (
$1
,( SELECT article_id FROM articles WHERE slug = $2 )
)
ON CONFLICT DO NOTHING
"#,
user_id,
article_slug,
)
.execute(self)
.await?;
Ok(())
}
async fn delete_favorite(
&mut self,
user_id: EntityId,
article_slug: &str,
) -> ProvideResult<()> {
sqlx::query!(
r#"
DELETE FROM favorite_articles
WHERE
user_id = $1
AND article_id = ( SELECT article_id FROM articles WHERE slug = $2 )
"#,
user_id,
article_slug,
)
.execute(self)
.await?;
Ok(())
}
async fn get_tags(&mut self) -> ProvideResult<Vec<String>> {
let tags = sqlx::query!(r#"SELECT DISTINCT tag_name from tags"#)
.fetch_all(self)
.await?;
Ok(tags.into_iter().map(|rec| rec.tag_name).collect::<Vec<_>>())
}
async fn create_comment(
&mut self,
article_slug: &str,
author_id: EntityId,
body: &str,
) -> ProvideResult<CommentEntity> {
let rec = sqlx::query_as!(
CommentEntity,
r#"
INSERT INTO comments ( article_id, author_id , body )
VALUES (
( SELECT article_id FROM articles WHERE slug = $1 )
, $2
, $3
)
RETURNING *
"#,
article_slug,
author_id,
body
)
.fetch_one(self)
.await?;
Ok(rec)
}
async fn delete_comment(
&mut self,
article_slug: &str,
comment_id: EntityId,
) -> ProvideResult<()> {
sqlx::query!(
r#"
DELETE FROM comments
WHERE
article_id = ( SELECT article_id FROM articles WHERE slug = $1 )
AND comment_id = $2
RETURNING comment_id
"#,
article_slug,
comment_id,
)
.fetch_one(self)
.await?;
Ok(())
}
async fn get_comment(
&mut self,
article_slug: &str,
comment_id: EntityId,
) -> ProvideResult<CommentEntity> {
let rec = sqlx::query_as!(
CommentEntity,
r#"
SELECT comments.*
FROM comments
INNER JOIN articles ON articles.slug = $1
WHERE comment_id = $2
"#,
article_slug,
comment_id,
)
.fetch_one(self)
.await?;
Ok(rec)
}
async fn get_comments_on_article(
&mut self,
article_slug: &str,
) -> ProvideResult<Vec<(CommentEntity, ProfileEntity)>> {
let recs = sqlx::query!(
r#"
SELECT
comments.*
, profiles.username, profiles.bio, profiles.image
FROM comments
INNER JOIN articles ON articles.slug = $1
INNER JOIN profiles ON profiles.user_id = comments.author_id
"#,
article_slug
)
.fetch_all(self)
.await?;
let entities = recs
.into_iter()
.map(|rec| {
let comment = CommentEntity {
comment_id: rec.comment_id,
body: rec.body,
article_id: rec.article_id,
author_id: rec.author_id,
created_at: rec.created_at,
updated_at: rec.updated_at,
};
let profile = ProfileEntity {
user_id: rec.author_id,
username: rec.username.unwrap(), // FIXME(pg): This column is not nullable
bio: rec.bio,
image: rec.image,
};
(comment, profile)
})
.collect::<Vec<_>>();
Ok(entities)
}
async fn get_profile_by_username(&mut self, username: &str) -> ProvideResult<ProfileEntity> {
let rec = sqlx::query_as!(
ProfileEntity,
r#"
SELECT user_id, username, bio, image
FROM profiles
WHERE username = $1
"#,
username,
)
.fetch_one(self)
.await?;
Ok(rec)
}
async fn get_profile_by_id(&mut self, profile_id: EntityId) -> ProvideResult<ProfileEntity> {
let rec = sqlx::query_as!(
ProfileEntity,
r#"
SELECT user_id, username, bio, image
FROM profiles
WHERE user_id = $1
"#,
profile_id
)
.fetch_one(self)
.await?;
Ok(rec)
}
async fn add_follower(
&mut self,
leader_username: &str,
follower_id: EntityId,
) -> ProvideResult<()> {
sqlx::query!(
r#"
INSERT INTO followers ( follower_id, leader_id )
VALUES (
$1,
( SELECT user_id FROM users WHERE username = $2 )
)
ON CONFLICT DO NOTHING
"#,
follower_id,
leader_username
)
.execute(self)
.await?;
Ok(())
}
async fn delete_follower(
&mut self,
leader_username: &str,
follower_id: EntityId,
) -> ProvideResult<()> {
sqlx::query!(
r#"
DELETE FROM followers
WHERE
leader_id = ( SELECT user_id FROM users WHERE username = $1 )
AND follower_id = $2
RETURNING follower_id
"#,
leader_username,
follower_id
)
.fetch_one(self)
.await?;
Ok(())
}
async fn is_following(
&mut self,
leader_id: EntityId,
follower_id: EntityId,
) -> ProvideResult<bool> {
let rec = sqlx::query!(
r#"
SELECT leader_id
FROM followers
WHERE leader_id = $1 AND follower_id = $2
"#,
leader_id,
follower_id,
)
.fetch_optional(self)
.await?;
Ok(rec.is_some())
}
async fn get_following(&mut self, follower_id: EntityId) -> ProvideResult<Vec<EntityId>> {
let recs = sqlx::query!(
r#"
SELECT leader_id from followers
WHERE follower_id = $1
"#,
follower_id
)
.fetch_all(self)
.await?;
Ok(recs.into_iter().map(|rec| rec.leader_id).collect())
}
}

View file

@ -1,80 +1,640 @@
use anyhow::{Result, Error};
use std::convert::TryFrom;
use anyhow::{Error, Result};
use async_trait::async_trait;
use sqlx::SqlitePool;
use chrono::{TimeZone, Utc};
use sqlx::pool::PoolConnection;
use sqlx::sqlite::{SqliteQueryAs, SqliteError};
use sqlx::Error as SqlxError;
use sqlx::{Connection, Cursor, Executor, FromRow, SqliteConnection, SqlitePool};
use sqlx::error::DatabaseError;
use super::model::*;
use crate::db::model::*;
use crate::db::Db;
pub async fn connect(db_url: &str) -> anyhow::Result<SqlitePool> {
impl TryFrom<&SqliteError> for ProvideError {
type Error = ();
/// Attempt to convert a Sqlite into a more-specific provider error
///
/// Unexpected cases will be bounced back to the caller for handling
///
/// * [Sqlite Error Codes](https://www.sqlite.org/rescode.html)
fn try_from(db_err: &SqliteError) -> Result<Self, Self::Error> {
let provider_err = match db_err.code().unwrap() {
"2067" => ProvideError::UniqueViolation(db_err.message().to_owned()),
_ => return Err(()),
};
Ok(provider_err)
}
}
#[derive(sqlx::FromRow)]
struct SqliteArticleEntity {
article_id: EntityId,
title: String,
slug: String,
description: String,
body: String,
author_id: EntityId,
created_at: i32,
updated_at: i32,
}
impl From<SqliteArticleEntity> for ArticleEntity {
fn from(entity: SqliteArticleEntity) -> Self {
let SqliteArticleEntity {
article_id,
title,
slug,
description,
body,
author_id,
created_at,
updated_at,
} = entity;
ArticleEntity {
article_id,
title,
slug,
description,
body,
author_id,
created_at: Utc.timestamp(created_at as _, 0),
updated_at: Utc.timestamp(updated_at as _, 0),
}
}
}
#[derive(sqlx::FromRow)]
struct SqliteCommentEntity {
comment_id: EntityId,
body: String,
article_id: EntityId,
author_id: EntityId,
created_at: EntityId,
updated_at: EntityId,
}
impl From<SqliteCommentEntity> for CommentEntity {
fn from(entity: SqliteCommentEntity) -> Self {
let SqliteCommentEntity {
comment_id,
body,
article_id,
author_id,
created_at,
updated_at,
} = entity;
CommentEntity {
comment_id,
body,
article_id,
author_id,
created_at: Utc.timestamp(created_at as _, 0),
updated_at: Utc.timestamp(updated_at as _, 0),
}
}
}
pub async fn connect(db_url: &str) -> sqlx::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?;
impl Db for SqlitePool {
type Conn = PoolConnection<SqliteConnection>;
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 fn conn(&self) -> sqlx::Result<Self::Conn> {
self.acquire().await
}
}
#[async_trait]
impl ProvideArticle for SqlitePool {
async fn create_article(&self) -> Result<ArticleEntity, Error> {
unimplemented!()
impl ProvideAuthn for SqliteConnection {
async fn create_user(
&mut self,
username: &str,
email: &str,
password: &str,
) -> ProvideResult<EntityId> {
let (user_id,): (EntityId,) = sqlx::query_as(
r#"
INSERT INTO users ( username, email, password )
VALUES ( $1, $2, $3 );
SELECT last_insert_rowid();
"#,
)
.bind(username)
.bind(email)
.bind(password)
.fetch_one(self)
.await?;
Ok(user_id)
}
async fn update_article(&self) -> Result<ArticleEntity, Error> {
unimplemented!()
async fn get_user_by_id(&mut self, user_id: EntityId) -> ProvideResult<UserEntity> {
let user = sqlx::query_as!(
UserEntity,
r#"
SELECT user_id, username, email, password, image, bio
FROM users
WHERE user_id = $1
"#,
user_id
)
.fetch_one(self)
.await?;
Ok(user)
}
async fn delete_article(&self) -> Result<ArticleEntity, Error> {
unimplemented!()
async fn get_user_by_email(&mut self, email: &str) -> ProvideResult<UserEntity> {
let user = sqlx::query_as!(
UserEntity,
r#"
SELECT user_id, username, email, password, image, bio
FROM users
WHERE email = $1
"#,
email
)
.fetch_one(self)
.await?;
Ok(user)
}
async fn update_user(&mut self, updated: &UserEntity) -> ProvideResult<()> {
sqlx::query!(
r#"
UPDATE users
SET email = $1, username = $2, password = $3, image = $4, bio = $5, updated_at = (STRFTIME('%s', 'now'))
WHERE user_id = $6
"#,
updated.email,
updated.username,
updated.password,
updated.image,
updated.bio,
updated.user_id,
)
.execute(self)
.await?;
Ok(())
}
}
#[async_trait]
impl ProvideData for SqliteConnection {
async fn create_article(
&mut self,
author_id: EntityId,
title: &str,
slug: &str,
description: &str,
body: &str,
) -> ProvideResult<ArticleEntity> {
let rec: SqliteArticleEntity = sqlx::query_as(
r#"
INSERT INTO articles ( title, slug, description, body, author_id )
VALUES ( $1, $2, $3, $4, $5);
SELECT * FROM articles WHERE article_id = last_insert_rowid();
"#,
)
.bind(title)
.bind(slug)
.bind(description)
.bind(body)
.bind(author_id)
.fetch_one(self)
.await?;
Ok(rec.into())
}
async fn create_tags_for_article(
&mut self,
article_id: EntityId,
tags: &'async_trait [impl AsRef<str> + Send + Sync],
) -> ProvideResult<()> {
let stmt = format!(
r#"
INSERT INTO TAGS (tag_name, article_id)
VALUES {}
"#,
super::build_batch_insert(tags.len(), 2)
);
tags.iter()
.fold(sqlx::query(&stmt), |q, tag_name| {
q.bind(tag_name.as_ref()).bind(article_id)
})
.execute(self)
.await?;
Ok(())
}
async fn update_article(&mut self, updated: &ArticleEntity) -> ProvideResult<ArticleEntity> {
self.execute("SAVEPOINT update_article").await?;
let update_stmt = sqlx::query!(
r#"
UPDATE articles
SET title = $2, slug = $3, description = $4, body = $5, updated_at = (STRFTIME('%s', 'now'))
WHERE article_id = $1
"#,
updated.article_id,
updated.title,
updated.slug,
updated.description,
updated.body,
);
self.execute(update_stmt).await?;
let select_stmt =
sqlx::query(r#"SELECT * FROM articles WHERE article_id = $1"#).bind(updated.article_id);
let rec = self
.fetch(select_stmt)
.next()
.await?
.map(|row| SqliteArticleEntity::from_row(&row).expect("invalid entity"))
.expect("Cursor should not be empty");
self.execute("RELEASE update_article").await?;
Ok(rec.into())
}
async fn delete_article(&mut self, slug: &str) -> Result<(), ProvideError> {
sqlx::query!(r#"DELETE FROM articles WHERE slug = $1"#, slug)
.execute(self)
.await?;
Ok(())
}
async fn get_article_by_slug(&mut self, slug: &str) -> Result<ArticleEntity, ProvideError> {
let rec: SqliteArticleEntity = sqlx::query_as(
r#"
SELECT *
FROM articles
WHERE slug = $1
"#,
)
.bind(slug)
.fetch_one(self)
.await?;
Ok(rec.into())
}
async fn get_all_articles(
&mut self,
) -> Result<Vec<(ArticleEntity, ProfileEntity)>, ProvideError> {
let recs = sqlx::query!(
r#"
SELECT
articles.*
,profiles.username, profiles.bio as bio, profiles.image
FROM articles
INNER JOIN profiles ON articles.author_id = profiles.user_id
ORDER BY created_at
"#
)
.fetch_all(self)
.await?;
let entities = recs
.into_iter()
.map(|rec| {
let article = SqliteArticleEntity {
article_id: rec.article_id,
title: rec.title,
slug: rec.slug,
description: rec.description,
body: rec.body,
author_id: rec.author_id,
created_at: rec.created_at,
updated_at: rec.updated_at,
};
let author = ProfileEntity {
user_id: rec.author_id,
username: rec.username,
bio: rec.bio,
image: rec.image,
};
(ArticleEntity::from(article), author)
})
.collect::<Vec<_>>();
Ok(entities)
}
async fn get_favorites_count(&mut self, article_slug: &str) -> Result<usize, ProvideError> {
// let (user_id, ): (EntityId, ) = sqlx::query_as(
let (count,): (i64,) = sqlx::query_as(
r#"
SELECT COUNT(favs.user_id) as count
FROM favorite_articles AS favs
INNER JOIN articles ON articles.article_id = favs.article_id
WHERE articles.slug = $1
"#,
)
.bind(article_slug)
.fetch_one(self)
.await?;
Ok(count as _)
}
async fn create_favorite(
&mut self,
user_id: EntityId,
article_slug: &str,
) -> ProvideResult<()> {
sqlx::query!(
r#"
INSERT INTO favorite_articles ( user_id, article_id )
VALUES (
$1
,( SELECT article_id FROM articles WHERE slug = $2 )
)
ON CONFLICT DO NOTHING
"#,
user_id,
article_slug,
)
.execute(self)
.await?;
Ok(())
}
async fn delete_favorite(
&mut self,
user_id: EntityId,
article_slug: &str,
) -> ProvideResult<()> {
sqlx::query!(
r#"
DELETE FROM favorite_articles
WHERE
user_id = $1
AND article_id = ( SELECT article_id FROM articles WHERE slug = $2 )
"#,
user_id,
article_slug,
)
.execute(self)
.await?;
Ok(())
}
async fn get_tags(&mut self) -> ProvideResult<Vec<String>> {
let tags = sqlx::query!(r#"SELECT DISTINCT tag_name from tags"#)
.fetch_all(self)
.await?;
Ok(tags.into_iter().map(|rec| rec.tag_name).collect::<Vec<_>>())
}
async fn create_comment(
&mut self,
article_slug: &str,
author_id: EntityId,
body: &str,
) -> ProvideResult<CommentEntity> {
self.execute("SAVEPOINT create_comment;").await?;
let insert_stmt = sqlx::query!(
r#"
INSERT INTO comments ( article_id, author_id , body )
VALUES (
( SELECT article_id FROM articles WHERE slug = $1 )
, $2
, $3
);
"#,
article_slug,
author_id,
body
);
self.execute(insert_stmt).await?;
let rec = self
.fetch("SELECT * FROM comments WHERE comment_id = last_insert_rowid()")
.next()
.await?
.map(|row| SqliteCommentEntity::from_row(&row).expect("Invalid entity"))
.expect("No row matching last_insert_rowid()");
self.execute("RELEASE create_comment;").await?;
Ok(rec.into())
}
async fn delete_comment(
&mut self,
article_slug: &str,
comment_id: EntityId,
) -> ProvideResult<()> {
sqlx::query!(
r#"
DELETE FROM comments
WHERE
article_id = ( SELECT article_id FROM articles WHERE slug = $1 )
AND comment_id = $2
"#,
article_slug,
comment_id,
)
.execute(self)
.await?;
Ok(())
}
async fn get_comment(
&mut self,
article_slug: &str,
comment_id: EntityId,
) -> ProvideResult<CommentEntity> {
let rec = sqlx::query_as!(
SqliteCommentEntity,
r#"
SELECT comments.*
FROM comments
INNER JOIN articles ON articles.slug = $1
WHERE comment_id = $2
"#,
article_slug,
comment_id,
)
.fetch_one(self)
.await?;
Ok(rec.into())
}
async fn get_comments_on_article(
&mut self,
article_slug: &str,
) -> ProvideResult<Vec<(CommentEntity, ProfileEntity)>> {
let recs = sqlx::query!(
r#"
SELECT
comments.*
, profiles.username, profiles.bio, profiles.image
FROM comments
INNER JOIN articles ON articles.slug = $1
INNER JOIN profiles ON profiles.user_id = comments.author_id
"#,
article_slug
)
.fetch_all(self)
.await?;
let entities = recs
.into_iter()
.map(|rec| {
let comment = SqliteCommentEntity {
comment_id: rec.comment_id,
body: rec.body,
article_id: rec.article_id,
author_id: rec.author_id,
created_at: rec.created_at,
updated_at: rec.updated_at,
};
let profile = ProfileEntity {
user_id: rec.author_id,
username: rec.username,
bio: rec.bio,
image: rec.image,
};
(CommentEntity::from(comment), profile)
})
.collect::<Vec<_>>();
Ok(entities)
}
async fn get_profile_by_username(&mut self, username: &str) -> ProvideResult<ProfileEntity> {
let rec = sqlx::query_as!(
ProfileEntity,
r#"
SELECT user_id, username, bio, image
FROM profiles
WHERE username = $1
"#,
username,
)
.fetch_one(self)
.await?;
Ok(rec)
}
async fn get_profile_by_id(&mut self, profile_id: EntityId) -> ProvideResult<ProfileEntity> {
let rec = sqlx::query_as!(
ProfileEntity,
r#"
SELECT user_id, username, bio, image
FROM profiles
WHERE user_id = $1
"#,
profile_id
)
.fetch_one(self)
.await?;
Ok(rec)
}
async fn add_follower(
&mut self,
leader_username: &str,
follower_id: EntityId,
) -> ProvideResult<()> {
sqlx::query!(
r#"
INSERT INTO followers ( follower_id, leader_id )
VALUES (
$1,
( SELECT user_id FROM users WHERE username = $2 )
)
ON CONFLICT DO NOTHING
"#,
follower_id,
leader_username
)
.execute(self)
.await?;
Ok(())
}
async fn delete_follower(
&mut self,
leader_username: &str,
follower_id: EntityId,
) -> ProvideResult<()> {
sqlx::query!(
r#"
DELETE FROM followers
WHERE
leader_id = ( SELECT user_id FROM users WHERE username = $1 )
AND follower_id = $2
"#,
leader_username,
follower_id
)
.execute(self)
.await?;
Ok(())
}
async fn is_following(
&mut self,
leader_id: EntityId,
follower_id: EntityId,
) -> ProvideResult<bool> {
let rec = sqlx::query!(
r#"
SELECT leader_id
FROM followers
WHERE leader_id = $1 AND follower_id = $2
"#,
leader_id,
follower_id,
)
.fetch_optional(self)
.await?;
Ok(rec.is_some())
}
async fn get_following(&mut self, follower_id: EntityId) -> ProvideResult<Vec<EntityId>> {
let recs = sqlx::query!(
r#"
SELECT leader_id from followers
WHERE follower_id = $1
"#,
follower_id
)
.fetch_all(self)
.await?;
Ok(recs.into_iter().map(|rec| rec.leader_id).collect())
}
}

View file

@ -1,3 +1,8 @@
/// Biz logic for the RealWorld backend API
///
/// See the [RealWorld API Spec](https://github.com/gothinkster/realworld/tree/master/api) for
/// more information on the API
pub mod api;
/// Database models and connectors
pub mod db;

View file

@ -1,7 +1,11 @@
use async_std::net::ToSocketAddrs;
use sqlx_example_realworld::db::model::*;
use sqlx_example_realworld::{api, db};
use sqlx_example_realworld::api::{articles, profiles, users};
use sqlx_example_realworld::db::Db;
use sqlx_example_realworld::db::model::{ProvideAuthn, ProvideData};
use sqlx_example_realworld::db;
use sqlx::pool::PoolConnection;
use tide::middleware::RequestLogger;
#[derive(structopt::StructOpt)]
struct Args {
@ -15,24 +19,55 @@ struct Args {
db: String,
}
async fn run_server<S>(addr: impl ToSocketAddrs, state: S) -> anyhow::Result<()>
async fn run_server<S, C>(addr: impl ToSocketAddrs, state: S) -> anyhow::Result<()>
where
S: Send + Sync + ProvideUser + ProvideArticle + 'static,
S: Send + Sync + Db<Conn=PoolConnection<C>> + 'static,
C: sqlx::Connect + ProvideAuthn + ProvideData
{
let mut server = tide::with_state(state);
server.at("/ping").get(|_| async move { "pong" }); // FIXME(sgg): remove
server.middleware(RequestLogger::new());
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);
// users
server.at("/api/users").post(users::register);
server.at("/api/users/login").post(users::login);
server.at("/api/user")
.get(users::get_current_user)
.put(users::update_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);
// profiles
server.at("/api/profiles/:username").get(profiles::get_profile);
server.at("/api/profiles/:username/follow")
.post(profiles::follow_user)
.delete(profiles::unfollow_user);
// articles
server.at("/api/articles")
.get(articles::list_articles)
.post(articles::create_article);
server.at("/api/articles/:slug")
.get(articles::get_article)
.put(articles::update_article)
.delete(articles::delete_article);
server.at("/api/articles/feed")
.get(articles::get_feed);
// favorites
server.at("/api/articles/:slug/favorite")
.post(articles::favorite_article)
.delete(articles::unfavorite_article);
// comments
server.at("/api/articles/:slug/comments")
.post(articles::add_comment)
.get(articles::get_comments);
server.at("/api/articles/:slug/comments/:comment_id")
.delete(articles::delete_comment);
// tags
server.at("/api/tags")
.get(articles::get_tags);
server.listen(addr).await?;