mirror of
https://github.com/launchbadge/sqlx
synced 2024-11-10 06:24:16 +00:00
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:
parent
7038dd8ab2
commit
8138a26b61
17 changed files with 2792 additions and 341 deletions
17
Cargo.lock
generated
17
Cargo.lock
generated
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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')
|
||||
);
|
||||
|
|
|
@ -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'))
|
||||
);
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
113
examples/realworld/src/api/model.rs
Normal file
113
examples/realworld/src/api/model.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
120
examples/realworld/src/api/profiles.rs
Normal file
120
examples/realworld/src/api/profiles.rs
Normal 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)
|
||||
}
|
|
@ -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 {
|
||||
|
|
102
examples/realworld/src/api/util.rs
Normal file
102
examples/realworld/src/api/util.rs
Normal 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()
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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?;
|
||||
|
||||
|
|
Loading…
Reference in a new issue