mirror of
https://github.com/launchbadge/sqlx
synced 2024-11-10 14:34:19 +00:00
Remove realworld example.
I'd prefer to have one but I'd rather not have one then one that doesn't compile. Ideally someone could take up maintaining a realworld example outside of SQLx. Until GATs though we should keep this to one database. Looking back, most of the reason this is so hard to migrate to 0.4 is because of how generic the bounds are.
This commit is contained in:
parent
f66025b460
commit
d04d612368
16 changed files with 0 additions and 3155 deletions
|
@ -1,35 +0,0 @@
|
|||
[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"
|
||||
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"] }
|
||||
serde_json = "1.0"
|
||||
sqlx = { path = "../../", features = ["chrono"]}
|
||||
structopt = { version = "0.3", features = ["paw"] }
|
||||
thiserror = "1.0.14"
|
||||
tide = "0.6.0"
|
|
@ -1,73 +0,0 @@
|
|||
# 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"}}'
|
||||
```
|
||||
|
||||
|
||||
## 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
|
|
@ -1,67 +0,0 @@
|
|||
CREATE TABLE IF NOT EXISTS users (
|
||||
user_id SERIAL PRIMARY KEY,
|
||||
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
bio TEXT,
|
||||
image 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 SERIAL 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 SERIAL 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')
|
||||
);
|
|
@ -1,67 +0,0 @@
|
|||
CREATE TABLE IF NOT EXISTS users (
|
||||
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,
|
||||
|
||||
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'))
|
||||
);
|
|
@ -1,616 +0,0 @@
|
|||
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};
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
/// The response body for multiple articles
|
||||
///
|
||||
/// [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 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(
|
||||
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
|
||||
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)
|
||||
}
|
|
@ -1,17 +0,0 @@
|
|||
/// 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;
|
||||
|
||||
/// Utility functions and traits
|
||||
pub mod util;
|
|
@ -1,116 +0,0 @@
|
|||
use std::collections::HashSet;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Serialize;
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,120 +0,0 @@
|
|||
use futures::TryFutureExt;
|
||||
use log::*;
|
||||
use serde::Serialize;
|
||||
use sqlx::pool::PoolConnection;
|
||||
use sqlx::{Connect, Connection};
|
||||
use tide::{Error, IntoResponse, Request, Response, ResultExt};
|
||||
|
||||
use crate::api::model::*;
|
||||
use crate::api::util::*;
|
||||
use crate::db::model::*;
|
||||
use crate::db::Db;
|
||||
|
||||
#[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)
|
||||
}
|
|
@ -1,363 +0,0 @@
|
|||
use std::default::Default;
|
||||
|
||||
use chrono::{Duration, Utc};
|
||||
use futures::TryFutureExt;
|
||||
use log::*;
|
||||
use rand::{thread_rng, RngCore};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use sqlx::pool::PoolConnection;
|
||||
use sqlx::{Connect, Connection};
|
||||
use tide::{Error, IntoResponse, Request, Response, ResultExt};
|
||||
|
||||
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>,
|
||||
pub username: String,
|
||||
pub bio: Option<String>,
|
||||
pub image: Option<String>,
|
||||
}
|
||||
|
||||
impl User {
|
||||
fn token(mut self, token: Option<String>) -> Self {
|
||||
self.token = token;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
// 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 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 id = db.create_user(&username, &email, &hashed_password).await?;
|
||||
|
||||
// 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);
|
||||
e
|
||||
})
|
||||
.ok();
|
||||
|
||||
let user = User {
|
||||
email,
|
||||
token,
|
||||
username,
|
||||
bio: None,
|
||||
image: None,
|
||||
};
|
||||
let resp = to_json_response(&UserResponseBody::from(user))?;
|
||||
|
||||
Ok::<_, Error>(resp)
|
||||
}
|
||||
.await
|
||||
.unwrap_or_else(IntoResponse::into_response)
|
||||
}
|
||||
|
||||
/// 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)?;
|
||||
|
||||
let state = req.state();
|
||||
let mut db = state.conn().await.server_err()?;
|
||||
|
||||
// n.b - the app doesn't support deleting users
|
||||
let user_ent = db.get_user_by_id(user_id).await?;
|
||||
|
||||
let resp = to_json_response(&UserResponseBody::from(
|
||||
User::from(user_ent).token(Some(token)),
|
||||
))?;
|
||||
|
||||
Ok::<_, Error>(resp)
|
||||
}
|
||||
.await
|
||||
.unwrap_or_else(IntoResponse::into_response)
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Creds {
|
||||
email: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
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 state = req.state();
|
||||
let mut db = state.conn().await.server_err()?;
|
||||
|
||||
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)
|
||||
})?;
|
||||
|
||||
debug!("User {} matches email {}", user_ent.user_id, &email);
|
||||
|
||||
let hashed_password = user_ent.password.as_str();
|
||||
|
||||
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_ent.user_id
|
||||
);
|
||||
let token = generate_token(user_ent.user_id).server_err()?;
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
#[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
|
||||
}
|
||||
};
|
||||
|
||||
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)
|
||||
}
|
||||
.await
|
||||
.unwrap_or_else(IntoResponse::into_response)
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// 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(24); // n.b. (bad for sec, good for testing)
|
||||
let token = jsonwebtoken::encode(
|
||||
&Header::default(),
|
||||
&TokenClaims {
|
||||
sub: user_id,
|
||||
exp: exp.timestamp(),
|
||||
},
|
||||
SECRET_KEY.as_ref(),
|
||||
)?;
|
||||
|
||||
Ok(token)
|
||||
}
|
|
@ -1,102 +0,0 @@
|
|||
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()
|
||||
}
|
||||
}
|
|
@ -1,43 +0,0 @@
|
|||
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()
|
||||
}
|
|
@ -1,214 +0,0 @@
|
|||
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 user_id: EntityId,
|
||||
pub email: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub bio: Option<String>,
|
||||
pub image: Option<String>,
|
||||
}
|
||||
|
||||
/// A type that can provide stable storage for user authentication
|
||||
///
|
||||
/// This provider is used for managing users and passwords
|
||||
#[async_trait]
|
||||
pub trait ProvideAuthn {
|
||||
async fn create_user(
|
||||
&mut self,
|
||||
username: &str,
|
||||
email: &str,
|
||||
password: &str,
|
||||
) -> ProvideResult<EntityId>;
|
||||
|
||||
async fn get_user_by_id(&mut self, user_id: EntityId) -> ProvideResult<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 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 ProvideData {
|
||||
async fn create_article(
|
||||
&mut self,
|
||||
author_id: EntityId,
|
||||
title: &str,
|
||||
slug: &str,
|
||||
description: &str,
|
||||
body: &str,
|
||||
) -> ProvideResult<ArticleEntity>;
|
||||
|
||||
async fn create_tags_for_article(
|
||||
&mut self,
|
||||
article_id: EntityId,
|
||||
tags: &'async_trait [impl AsRef<str> + Send + Sync],
|
||||
) -> ProvideResult<()>;
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,564 +0,0 @@
|
|||
use std::convert::TryFrom;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sqlx::error::DatabaseError;
|
||||
use sqlx::pool::PoolConnection;
|
||||
use sqlx::postgres::PgError;
|
||||
use sqlx::{PgConnection, PgPool};
|
||||
|
||||
use crate::db::model::*;
|
||||
use crate::db::Db;
|
||||
|
||||
/// 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 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(
|
||||
&mut self,
|
||||
username: &str,
|
||||
email: &str,
|
||||
password: &str,
|
||||
) -> ProvideResult<EntityId> {
|
||||
let user_id = sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO users ( username, email, password )
|
||||
VALUES ( $1, $2, $3 )
|
||||
RETURNING user_id
|
||||
"#,
|
||||
username,
|
||||
email,
|
||||
password
|
||||
)
|
||||
.fetch_one(self)
|
||||
.await
|
||||
.map(|rec| rec.user_id)?;
|
||||
|
||||
Ok(user_id)
|
||||
}
|
||||
|
||||
async fn get_user_by_id(&mut self, user_id: i32) -> ProvideResult<UserEntity> {
|
||||
let rec = 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(rec)
|
||||
}
|
||||
|
||||
async fn get_user_by_email(&mut self, email: &str) -> ProvideResult<UserEntity> {
|
||||
let rec = sqlx::query_as!(
|
||||
UserEntity,
|
||||
r#"
|
||||
SELECT user_id, username, email, password, image, bio
|
||||
FROM users
|
||||
WHERE email = $1
|
||||
"#,
|
||||
email
|
||||
)
|
||||
.fetch_one(self)
|
||||
.await?;
|
||||
|
||||
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 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 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> {
|
||||
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())
|
||||
}
|
||||
}
|
|
@ -1,637 +0,0 @@
|
|||
use std::convert::TryFrom;
|
||||
|
||||
use anyhow::{Error, Result};
|
||||
use async_trait::async_trait;
|
||||
use chrono::{TimeZone, Utc};
|
||||
use sqlx::error::DatabaseError;
|
||||
use sqlx::pool::PoolConnection;
|
||||
use sqlx::sqlite::SqliteError;
|
||||
use sqlx::Error as SqlxError;
|
||||
use sqlx::{Connection, Cursor, Executor, FromRow, SqliteConnection, SqlitePool};
|
||||
|
||||
use crate::db::model::*;
|
||||
use crate::db::Db;
|
||||
|
||||
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 Db for SqlitePool {
|
||||
type Conn = PoolConnection<SqliteConnection>;
|
||||
|
||||
async fn conn(&self) -> sqlx::Result<Self::Conn> {
|
||||
self.acquire().await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
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 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 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())
|
||||
}
|
||||
}
|
|
@ -1,8 +0,0 @@
|
|||
/// 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;
|
|
@ -1,113 +0,0 @@
|
|||
use async_std::net::ToSocketAddrs;
|
||||
|
||||
use sqlx::pool::PoolConnection;
|
||||
use sqlx_example_realworld::api::{articles, profiles, users};
|
||||
use sqlx_example_realworld::db;
|
||||
use sqlx_example_realworld::db::model::{ProvideAuthn, ProvideData};
|
||||
use sqlx_example_realworld::db::Db;
|
||||
use tide::middleware::RequestLogger;
|
||||
|
||||
#[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, C>(addr: impl ToSocketAddrs, state: S) -> anyhow::Result<()>
|
||||
where
|
||||
S: Send + Sync + Db<Conn = PoolConnection<C>> + 'static,
|
||||
C: sqlx::Connect + ProvideAuthn + ProvideData,
|
||||
{
|
||||
let mut server = tide::with_state(state);
|
||||
|
||||
server.middleware(RequestLogger::new());
|
||||
|
||||
// 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);
|
||||
|
||||
// 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?;
|
||||
|
||||
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…
Reference in a new issue