diff --git a/README.md b/README.md index f2f39bc..3f57e7b 100644 --- a/README.md +++ b/README.md @@ -68,26 +68,35 @@ $ APIURL=http://localhost:8080/api zsh e2e/run-api-tests.sh ## Architecture -### Flow from Request to Response +- Clean Architecture +- DI container using Constructor Injection with dynamic dispatch (`/src/di.rs`) ```mermaid sequenceDiagram actor Client - participant Middleware as Middleware
/middleware/* - participant Controller as Controller
/[feature]/api.rs - participant Service as Service
/[feature]/service.rs + autonumber + participant Route as Middleware + Route

/src/app/drivers/{middlewares, routes} + participant Controller as Controller

/src/app/features/[feature]/controllers.rs + participant Presenter as Presenter

/src/app/features/[feature]/presenters.rs + participant Usecase as Usecase

/src/app/features/[feature]/usecases.rs + participant Repository as Repository

/src/app/features/[feature]/repositories.rs + participant Entity as Entity

/src/app/features/[feature]/entities.rs participant DB - Client ->> Middleware: request - Middleware ->> Controller: - - Controller ->> Controller: Assign to Request Object
(/[feature]/request.rs) - Controller ->> Service: - - Service ->> DB: - - - DB ->> Service: - - Service ->> Controller: - - Controller ->> Controller: Convert into Response Object
(/[feature]/response.rs) - Controller ->> Client: response + %% left to right + Client -->> Route: Request + Route ->> Controller:
+ Controller ->> Usecase:
+ Usecase ->> Repository:
+ Repository ->> Entity:
+ Entity ->> DB:
+ + %% right to left + DB ->> Entity:
+ Entity ->> Repository:
+ Repository ->> Usecase:
+ Usecase ->> Presenter:
+ Presenter -->> Client: Response ``` ## LICENSE diff --git a/src/app/article/api.rs b/src/app/article/api.rs deleted file mode 100644 index ee6b81b..0000000 --- a/src/app/article/api.rs +++ /dev/null @@ -1,153 +0,0 @@ -use super::{ - model::{Article, DeleteArticle}, - request, - response::{MultipleArticlesResponse, SingleArticleResponse}, - service, -}; -use crate::middleware::auth; -use crate::middleware::state::AppState; -use crate::utils::api::ApiResponse; -use actix_web::{web, HttpRequest, HttpResponse}; -use serde::Deserialize; - -type ArticleTitleSlug = String; - -#[derive(Deserialize)] -pub struct ArticlesListQueryParameter { - tag: Option, - author: Option, - favorited: Option, - limit: Option, - offset: Option, -} - -pub async fn index( - state: web::Data, - params: web::Query, -) -> ApiResponse { - let conn = &mut state.get_conn()?; - let offset = std::cmp::min(params.offset.to_owned().unwrap_or(0), 100); - let limit = params.limit.unwrap_or(20); - - let (articles_list, articles_count) = service::fetch_articles_list( - conn, - service::FetchArticlesList { - tag: params.tag.clone(), - author: params.author.clone(), - favorited: params.favorited.clone(), - offset, - limit, - }, - )?; - - let res = MultipleArticlesResponse::from((articles_list, articles_count)); - Ok(HttpResponse::Ok().json(res)) -} - -#[derive(Deserialize)] -pub struct FeedQueryParameter { - limit: Option, - offset: Option, -} - -pub async fn feed( - state: web::Data, - req: HttpRequest, - params: web::Query, -) -> ApiResponse { - let conn = &mut state.get_conn()?; - let current_user = auth::get_current_user(&req)?; - let offset = std::cmp::min(params.offset.to_owned().unwrap_or(0), 100); - let limit = params.limit.unwrap_or(20); - let (articles_list, articles_count) = service::fetch_following_articles( - conn, - &service::FetchFollowedArticlesSerivce { - current_user, - offset, - limit, - }, - )?; - - let res = MultipleArticlesResponse::from((articles_list, articles_count)); - Ok(HttpResponse::Ok().json(res)) -} - -pub async fn show(state: web::Data, path: web::Path) -> ApiResponse { - let conn = &mut state.get_conn()?; - let article_title_slug = path.into_inner(); - let (article, profile, favorite_info, tags_list) = - service::fetch_article_by_slug(conn, &service::FetchArticleBySlug { article_title_slug })?; - let res = SingleArticleResponse::from((article, profile, favorite_info, tags_list)); - Ok(HttpResponse::Ok().json(res)) -} - -pub async fn create( - state: web::Data, - req: HttpRequest, - form: web::Json, -) -> ApiResponse { - let conn = &mut state.get_conn()?; - let current_user = auth::get_current_user(&req)?; - let (article, profile, favorite_info, tag_list) = service::create( - conn, - &service::CreateArticleSerivce { - title: form.article.title.clone(), - slug: Article::convert_title_to_slug(&form.article.title), - description: form.article.description.clone(), - body: form.article.body.clone(), - tag_name_list: form.article.tag_list.to_owned(), - current_user, - }, - )?; - let res = SingleArticleResponse::from((article, profile, favorite_info, tag_list)); - Ok(HttpResponse::Ok().json(res)) -} - -pub async fn update( - state: web::Data, - req: HttpRequest, - path: web::Path, - form: web::Json, -) -> ApiResponse { - let conn = &mut state.get_conn()?; - let current_user = auth::get_current_user(&req)?; - let article_title_slug = path.into_inner(); - let article_slug = &form - .article - .title - .as_ref() - .map(|_title| Article::convert_title_to_slug(_title)); - - let (article, profile, favorite_info, tag_list) = service::update_article( - conn, - &service::UpdateArticleService { - current_user, - article_title_slug, - slug: article_slug.to_owned(), - title: form.article.title.clone(), - description: form.article.description.clone(), - body: form.article.body.clone(), - }, - )?; - - let res = SingleArticleResponse::from((article, profile, favorite_info, tag_list)); - Ok(HttpResponse::Ok().json(res)) -} - -pub async fn delete( - state: web::Data, - req: HttpRequest, - path: web::Path, -) -> ApiResponse { - let conn = &mut state.get_conn()?; - let current_user = auth::get_current_user(&req)?; - let article_title_slug = path.into_inner(); - Article::delete( - conn, - &DeleteArticle { - slug: article_title_slug, - author_id: current_user.id, - }, - )?; - Ok(HttpResponse::Ok().json(())) -} diff --git a/src/app/article/mod.rs b/src/app/article/mod.rs deleted file mode 100644 index c4c0061..0000000 --- a/src/app/article/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod api; -pub mod model; -pub mod request; -pub mod response; -pub mod service; diff --git a/src/app/article/service.rs b/src/app/article/service.rs deleted file mode 100644 index 9972950..0000000 --- a/src/app/article/service.rs +++ /dev/null @@ -1,390 +0,0 @@ -use crate::app::article::model::{Article, CreateArticle, UpdateArticle}; -use crate::app::favorite::model::{Favorite, FavoriteInfo}; -use crate::app::follow::model::Follow; -use crate::app::profile::model::Profile; -use crate::app::tag::model::{CreateTag, Tag}; -use crate::app::user::model::User; -use crate::error::AppError; -use crate::schema::articles::dsl::*; -use crate::schema::{articles, tags, users}; -use diesel::pg::PgConnection; -use diesel::prelude::*; -use uuid::Uuid; - -pub struct CreateArticleSerivce { - pub slug: String, - pub title: String, - pub description: String, - pub body: String, - pub tag_name_list: Option>, - pub current_user: User, -} -pub fn create( - conn: &mut PgConnection, - params: &CreateArticleSerivce, -) -> Result<(Article, Profile, FavoriteInfo, Vec), AppError> { - let article = Article::create( - conn, - &CreateArticle { - author_id: params.current_user.id, - slug: params.slug.clone(), - title: params.title.clone(), - description: params.description.clone(), - body: params.body.clone(), - }, - )?; - let tag_list = create_tag_list(conn, ¶ms.tag_name_list, &article.id)?; - - let profile = params - .current_user - .fetch_profile(conn, &article.author_id)?; - - let favorite_info = { - let is_favorited = article.is_favorited_by_user_id(conn, ¶ms.current_user.id)?; - let favorites_count = article.fetch_favorites_count(conn)?; - FavoriteInfo { - is_favorited, - favorites_count, - } - }; - - Ok((article, profile, favorite_info, tag_list)) -} - -fn create_tag_list( - conn: &mut PgConnection, - tag_name_list: &Option>, - article_id: &Uuid, -) -> Result, AppError> { - let list = tag_name_list - .as_ref() - .map(|tag_name_list| { - let records = tag_name_list - .iter() - .map(|name| CreateTag { name, article_id }) - .collect(); - Tag::create_list(conn, records) - }) - .unwrap_or_else(|| Ok(vec![])); - list -} - -pub struct FetchArticlesList { - pub tag: Option, - pub author: Option, - pub favorited: Option, - pub offset: i64, - pub limit: i64, -} - -type ArticlesCount = i64; -type ArticlesListInner = (Article, Profile, FavoriteInfo); -type ArticlesList = Vec<(ArticlesListInner, Vec)>; -pub fn fetch_articles_list( - conn: &mut PgConnection, - params: FetchArticlesList, -) -> Result<(ArticlesList, ArticlesCount), AppError> { - use diesel::prelude::*; - - let query = { - let mut query = articles::table.inner_join(users::table).into_boxed(); - - if let Some(tag_name) = ¶ms.tag { - let ids = Tag::fetch_article_ids_by_name(conn, tag_name) - .expect("could not fetch tagged article ids."); // TODO: use ? or error handling - query = query.filter(articles::id.eq_any(ids)); - } - - if let Some(author_name) = ¶ms.author { - let ids = Article::fetch_ids_by_author_name(conn, author_name) - .expect("could not fetch authors id."); // TODO: use ? or error handling - query = query.filter(articles::id.eq_any(ids)); - } - - if let Some(username) = ¶ms.favorited { - let ids = Favorite::fetch_favorited_article_ids_by_username(conn, username) - .expect("could not fetch favorited articles id."); // TODO: use ? or error handling - - query = query.filter(articles::id.eq_any(ids)); - } - - query - }; - let articles_count = query - .select(diesel::dsl::count(articles::id)) - .first::(conn)?; - - let list = { - let query = { - let mut query = articles::table.inner_join(users::table).into_boxed(); - - if let Some(tag_name) = ¶ms.tag { - let ids = Tag::fetch_article_ids_by_name(conn, tag_name) - .expect("could not fetch tagged article ids."); // TODO: use ? or error handling - query = query.filter(articles::id.eq_any(ids)); - } - - if let Some(author_name) = ¶ms.author { - let ids = Article::fetch_ids_by_author_name(conn, author_name) - .expect("could not fetch authors id."); // TODO: use ? or error handling - query = query.filter(articles::id.eq_any(ids)); - } - - if let Some(username) = ¶ms.favorited { - let ids = Favorite::fetch_favorited_article_ids_by_username(conn, username) - .expect("could not fetch favorited articles id."); // TODO: use ? or error handling - - query = query.filter(articles::id.eq_any(ids)); - } - - query - }; - let article_and_user_list = - query - .offset(params.offset) - .limit(params.limit) - .load::<(Article, User)>(conn)?; - - let tags_list = { - let articles_list = article_and_user_list - .clone() - .into_iter() - .map(|(article, _)| article) - .collect::>(); - let tags_list = Tag::belonging_to(&articles_list) - .order(tags::name.asc()) - .load::(conn)?; - let tags_list: Vec> = tags_list.grouped_by(&articles_list); - tags_list - }; - - let favorites_count_list = { - let list: Result, _> = article_and_user_list - .clone() - .into_iter() - .map(|(article, _)| article.fetch_favorites_count(conn)) - .collect(); - - list? - }; - - article_and_user_list - .into_iter() - .zip(favorites_count_list) - .map(|((article, user), favorites_count)| { - ( - article, - Profile { - username: user.username, - bio: user.bio, - image: user.image, - following: false, // NOTE: because not authz - }, - FavoriteInfo { - is_favorited: false, // NOTE: because not authz - favorites_count, - }, - ) - }) - .zip(tags_list) - .collect::>() - }; - - Ok((list, articles_count)) -} - -pub struct FetchArticle { - pub article_id: Uuid, - pub current_user: User, -} -pub fn fetch_article( - conn: &mut PgConnection, - FetchArticle { - article_id, - current_user, - }: &FetchArticle, -) -> Result<(Article, Profile, FavoriteInfo, Vec), AppError> { - let (article, author) = Article::find_with_author(conn, article_id)?; - - let profile = current_user.fetch_profile(conn, &author.id)?; - - let favorite_info = { - let is_favorited = article.is_favorited_by_user_id(conn, ¤t_user.id)?; - let favorites_count = article.fetch_favorites_count(conn)?; - FavoriteInfo { - is_favorited, - favorites_count, - } - }; - - let tags_list = Tag::belonging_to(&article).load::(conn)?; - - Ok((article, profile, favorite_info, tags_list)) -} - -pub struct FetchArticleBySlug { - pub article_title_slug: String, -} -pub fn fetch_article_by_slug( - conn: &mut PgConnection, - params: &FetchArticleBySlug, -) -> Result<(Article, Profile, FavoriteInfo, Vec), AppError> { - let (article, author) = Article::fetch_by_slug_with_author(conn, ¶ms.article_title_slug)?; - - let profile = author.fetch_profile(conn, &author.id)?; - - let tags_list = Tag::belonging_to(&article).load::(conn)?; - - let favorite_info = { - let is_favorited = article.is_favorited_by_user_id(conn, &author.id)?; - let favorites_count = article.fetch_favorites_count(conn)?; - FavoriteInfo { - is_favorited, - favorites_count, - } - }; - - Ok((article, profile, favorite_info, tags_list)) -} - -use crate::schema::follows; -pub struct FetchFollowedArticlesSerivce { - pub current_user: User, - pub offset: i64, - pub limit: i64, -} -pub fn fetch_following_articles( - conn: &mut PgConnection, - params: &FetchFollowedArticlesSerivce, -) -> Result<(ArticlesList, ArticlesCount), AppError> { - let create_query = { - let ids = Follow::fetch_folowee_ids_by_follower_id(conn, ¶ms.current_user.id)?; - articles.filter(articles::author_id.eq_any(ids)) - }; - - let articles_list = { - let article_and_user_list = create_query - .to_owned() - .inner_join(users::table) - .limit(params.limit) - .offset(params.offset) - .order(articles::created_at.desc()) - .get_results::<(Article, User)>(conn)?; - - let tags_list = { - let articles_list = article_and_user_list - .clone() // TODO: avoid clone - .into_iter() - .map(|(article, _)| article) - .collect::>(); - - let tags_list = Tag::belonging_to(&articles_list).load::(conn)?; - let tags_list: Vec> = tags_list.grouped_by(&articles_list); - tags_list - }; - - let follows_list = { - let user_ids_list = article_and_user_list - .clone() // TODO: avoid clone - .into_iter() - .map(|(_, user)| user.id) - .collect::>(); - - let list = follows::table - .filter(Follow::with_follower(¶ms.current_user.id)) - .filter(follows::followee_id.eq_any(user_ids_list)) - .get_results::(conn)?; - - list.into_iter() - }; - - let favorites_count_list = { - let list: Result, _> = article_and_user_list - .clone() - .into_iter() - .map(|(article, _)| article.fetch_favorites_count(conn)) - .collect(); - - list? - }; - - let favorited_article_ids = params.current_user.fetch_favorited_article_ids(conn)?; - let is_favorited_by_me = |article: &Article| { - favorited_article_ids - .iter() - .copied() - .any(|_id| _id == article.id) - }; - - article_and_user_list - .into_iter() - .zip(favorites_count_list) - .map(|((article, user), favorites_count)| { - let following = follows_list.clone().any(|item| item.followee_id == user.id); - let is_favorited = is_favorited_by_me(&article); - ( - article, - Profile { - username: user.username, - bio: user.bio, - image: user.image, - following: following.to_owned(), - }, - FavoriteInfo { - is_favorited, - favorites_count, - }, - ) - }) - .zip(tags_list) - .collect::>() - }; - - let articles_count = create_query - .select(diesel::dsl::count(articles::id)) - .first::(conn)?; - - Ok((articles_list, articles_count)) -} - -pub struct UpdateArticleService { - pub current_user: User, - pub article_title_slug: String, - pub slug: Option, - pub title: Option, - pub description: Option, - pub body: Option, -} -pub fn update_article( - conn: &mut PgConnection, - params: &UpdateArticleService, -) -> Result<(Article, Profile, FavoriteInfo, Vec), AppError> { - let article = Article::update( - conn, - ¶ms.article_title_slug, - ¶ms.current_user.id, - &UpdateArticle { - slug: params.slug.to_owned(), - title: params.title.to_owned(), - description: params.description.to_owned(), - body: params.body.to_owned(), - }, - )?; - - let tag_list = Tag::fetch_by_article_id(conn, &article.id)?; - - let profile = params - .current_user - .fetch_profile(conn, &article.author_id)?; - - let favorite_info = { - let is_favorited = article.is_favorited_by_user_id(conn, ¶ms.current_user.id)?; - let favorites_count = article.fetch_favorites_count(conn)?; - FavoriteInfo { - is_favorited, - favorites_count, - } - }; - - Ok((article, profile, favorite_info, tag_list)) -} diff --git a/src/app/comment/api.rs b/src/app/comment/api.rs deleted file mode 100644 index 69e320e..0000000 --- a/src/app/comment/api.rs +++ /dev/null @@ -1,62 +0,0 @@ -use super::{ - request, - response::{MultipleCommentsResponse, SingleCommentResponse}, - service, -}; -use crate::middleware::auth; -use crate::middleware::state::AppState; -use crate::utils::api::ApiResponse; -use crate::utils::uuid; -use actix_web::{web, HttpRequest, HttpResponse}; - -type ArticleIdSlug = String; -type CommentIdSlug = String; - -pub async fn index(state: web::Data, req: HttpRequest) -> ApiResponse { - let conn = &mut state.get_conn()?; - let current_user = auth::get_current_user(&req).ok(); - let list = service::fetch_comments_list(conn, ¤t_user)?; - let res = MultipleCommentsResponse::from(list); - Ok(HttpResponse::Ok().json(res)) -} - -pub async fn create( - state: web::Data, - req: HttpRequest, - path: web::Path, - form: web::Json, -) -> ApiResponse { - let conn = &mut state.get_conn()?; - let current_user = auth::get_current_user(&req)?; - let article_title_slug = path.into_inner(); - let (comment, profile) = service::create( - conn, - &service::CreateCommentService { - body: form.comment.body.to_owned(), - article_title_slug, - author: current_user, - }, - )?; - let res = SingleCommentResponse::from((comment, profile)); - Ok(HttpResponse::Ok().json(res)) -} - -pub async fn delete( - state: web::Data, - req: HttpRequest, - path: web::Path<(ArticleIdSlug, CommentIdSlug)>, -) -> ApiResponse { - let conn = &mut state.get_conn()?; - let current_user = auth::get_current_user(&req)?; - let (article_title_slug, comment_id) = path.into_inner(); - let comment_id = uuid::parse(&comment_id)?; - service::delete_comment( - conn, - &service::DeleteCommentService { - article_title_slug, - comment_id, - author_id: current_user.id, - }, - )?; - Ok(HttpResponse::Ok().json("Ok")) -} diff --git a/src/app/comment/mod.rs b/src/app/comment/mod.rs deleted file mode 100644 index c4c0061..0000000 --- a/src/app/comment/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod api; -pub mod model; -pub mod request; -pub mod response; -pub mod service; diff --git a/src/app/comment/service.rs b/src/app/comment/service.rs deleted file mode 100644 index f00b777..0000000 --- a/src/app/comment/service.rs +++ /dev/null @@ -1,98 +0,0 @@ -use super::model::{Comment, CreateComment, DeleteComment}; -use crate::app::article::model::{Article, FetchBySlugAndAuthorId}; -use crate::app::profile::model::Profile; -use crate::app::profile::service::{conver_user_to_profile, ConverUserToProfile}; -use crate::app::user::model::User; -use crate::error::AppError; -use diesel::pg::PgConnection; -use uuid::Uuid; - -pub struct CreateCommentService { - pub body: String, - pub article_title_slug: String, - pub author: User, -} -pub fn create( - conn: &mut PgConnection, - params: &CreateCommentService, -) -> Result<(Comment, Profile), AppError> { - let CreateCommentService { - body, - article_title_slug, - author, - } = params; - let article = Article::fetch_by_slug_and_author_id( - conn, - &FetchBySlugAndAuthorId { - slug: article_title_slug.to_owned(), - author_id: author.id, - }, - )?; - let comment = Comment::create( - conn, - &CreateComment { - body: body.to_string(), - author_id: author.id, - article_id: article.id.to_owned(), - }, - )?; - let profile = author.fetch_profile(conn, &author.id)?; - Ok((comment, profile)) -} - -pub fn fetch_comments_list( - conn: &mut PgConnection, - current_user: &Option, -) -> Result, AppError> { - let comments = { - use crate::schema::comments; - // use crate::schema::comments::dsl::*; - use crate::schema::users; - use diesel::prelude::*; - comments::table - .inner_join(users::table) - // .filter(comments::article_id.eq(article_id)) - .get_results::<(Comment, User)>(conn)? - }; - - let comments = comments - .iter() - .map(|(comment, user)| { - // TODO: avoid N+1. Write one query to fetch all data somehow. - let profile = conver_user_to_profile(conn, &ConverUserToProfile { user, current_user }); - - // TODO: avoid copy - (comment.to_owned(), profile) - }) - .collect::>(); - - Ok(comments) -} - -pub struct DeleteCommentService { - pub article_title_slug: String, - pub author_id: Uuid, - pub comment_id: Uuid, -} - -pub fn delete_comment( - conn: &mut PgConnection, - params: &DeleteCommentService, -) -> Result<(), AppError> { - let article = Article::fetch_by_slug_and_author_id( - conn, - &FetchBySlugAndAuthorId { - slug: params.article_title_slug.to_owned(), - author_id: params.author_id, - }, - )?; - Comment::delete( - conn, - &DeleteComment { - comment_id: params.comment_id, - article_id: article.id, - author_id: params.author_id, - }, - )?; - Ok(()) -} diff --git a/src/middleware/auth.rs b/src/app/drivers/middlewares/auth.rs similarity index 98% rename from src/middleware/auth.rs rename to src/app/drivers/middlewares/auth.rs index acb27d3..502cb39 100644 --- a/src/middleware/auth.rs +++ b/src/app/drivers/middlewares/auth.rs @@ -1,7 +1,7 @@ -use crate::app::user::model::User; +use crate::app::drivers::middlewares::state::AppState; +use crate::app::features::user::entities::User; use crate::constants; use crate::error::AppError; -use crate::middleware::state::AppState; use crate::utils::token; use actix_web::HttpMessage; use actix_web::{ diff --git a/src/middleware/cors.rs b/src/app/drivers/middlewares/cors.rs similarity index 100% rename from src/middleware/cors.rs rename to src/app/drivers/middlewares/cors.rs diff --git a/src/middleware/error.rs b/src/app/drivers/middlewares/error.rs similarity index 100% rename from src/middleware/error.rs rename to src/app/drivers/middlewares/error.rs diff --git a/src/middleware/mod.rs b/src/app/drivers/middlewares/mod.rs similarity index 100% rename from src/middleware/mod.rs rename to src/app/drivers/middlewares/mod.rs diff --git a/src/middleware/state.rs b/src/app/drivers/middlewares/state.rs similarity index 56% rename from src/middleware/state.rs rename to src/app/drivers/middlewares/state.rs index fee3577..65ea3ec 100644 --- a/src/middleware/state.rs +++ b/src/app/drivers/middlewares/state.rs @@ -1,5 +1,6 @@ use crate::error::AppError; -use crate::utils; +use crate::utils::db::DbPool; +use crate::utils::di::DiContainer; use diesel::pg::PgConnection; use diesel::r2d2::{ConnectionManager, PooledConnection}; @@ -7,10 +8,17 @@ type AppConn = PooledConnection>; #[derive(Clone)] pub struct AppState { - pub pool: utils::db::DbPool, + #[deprecated] + pub pool: DbPool, + pub di_container: DiContainer, } impl AppState { + pub fn new(pool: DbPool) -> Self { + let di_container = DiContainer::new(&pool); + Self { pool, di_container } + } + pub fn get_conn(&self) -> Result { let conn = self.pool.get()?; Ok(conn) diff --git a/src/app/drivers/mod.rs b/src/app/drivers/mod.rs new file mode 100644 index 0000000..c58f3b1 --- /dev/null +++ b/src/app/drivers/mod.rs @@ -0,0 +1,2 @@ +pub mod middlewares; +pub mod routes; diff --git a/src/app/drivers/routes.rs b/src/app/drivers/routes.rs new file mode 100644 index 0000000..8951eb3 --- /dev/null +++ b/src/app/drivers/routes.rs @@ -0,0 +1,80 @@ +use crate::app; +use actix_web::web; +use actix_web::web::{delete, get, post, put}; + +pub fn api(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("/api") + .service( + web::scope("/healthcheck") + .route("", get().to(app::features::healthcheck::controllers::index)), + ) + .service( + web::scope("/tags").route("", get().to(app::features::tag::controllers::index)), + ) + .service( + web::scope("/users") + .route( + "/login", + post().to(app::features::user::controllers::signin), + ) + .route("", post().to(app::features::user::controllers::signup)), + ) + .service( + web::scope("/user") + .route("", get().to(app::features::user::controllers::me)) + .route("", put().to(app::features::user::controllers::update)), + ) + .service( + web::scope("/profiles") + .route( + "/{username}", + get().to(app::features::profile::controllers::show), + ) + .route( + "/{username}/follow", + post().to(app::features::profile::controllers::follow), + ) + .route( + "/{username}/follow", + delete().to(app::features::profile::controllers::unfollow), + ), + ) + .service( + web::scope("/articles") + .route("/feed", get().to(app::features::article::controllers::feed)) + .route("", get().to(app::features::article::controllers::index)) + .route("", post().to(app::features::article::controllers::create)) + .service( + web::scope("/{article_title_slug}") + .route("", get().to(app::features::article::controllers::show)) + .route("", put().to(app::features::article::controllers::update)) + .route("", delete().to(app::features::article::controllers::delete)) + .service( + web::scope("/favorite") + .route( + "", + post().to(app::features::favorite::controllers::favorite), + ) + .route( + "", + delete() + .to(app::features::favorite::controllers::unfavorite), + ), + ) + .service( + web::scope("/comments") + .route("", get().to(app::features::comment::controllers::index)) + .route( + "", + post().to(app::features::comment::controllers::create), + ) + .route( + "/{comment_id}", + delete().to(app::features::comment::controllers::delete), + ), + ), + ), + ), + ); +} diff --git a/src/app/favorite/api.rs b/src/app/favorite/api.rs deleted file mode 100644 index 52568b2..0000000 --- a/src/app/favorite/api.rs +++ /dev/null @@ -1,48 +0,0 @@ -use super::{ - response::SingleArticleResponse, - service::{self, UnfavoriteService}, -}; -use crate::middleware::auth; -use crate::middleware::state::AppState; -use crate::utils::api::ApiResponse; -use actix_web::{web, HttpRequest, HttpResponse}; - -type ArticleIdSlug = String; - -pub async fn favorite( - state: web::Data, - req: HttpRequest, - path: web::Path, -) -> ApiResponse { - let conn = &mut state.get_conn()?; - let current_user = auth::get_current_user(&req)?; - let article_title_slug = path.into_inner(); - let (article, profile, favorite_info, tags_list) = service::favorite( - conn, - &service::FavoriteService { - current_user, - article_title_slug, - }, - )?; - let res = SingleArticleResponse::from((article, profile, favorite_info, tags_list)); - Ok(HttpResponse::Ok().json(res)) -} - -pub async fn unfavorite( - state: web::Data, - req: HttpRequest, - path: web::Path, -) -> ApiResponse { - let conn = &mut state.get_conn()?; - let current_user = auth::get_current_user(&req)?; - let article_title_slug = path.into_inner(); - let (article, profile, favorite_info, tags_list) = service::unfavorite( - conn, - &UnfavoriteService { - current_user, - article_title_slug, - }, - )?; - let res = SingleArticleResponse::from((article, profile, favorite_info, tags_list)); - Ok(HttpResponse::Ok().json(res)) -} diff --git a/src/app/favorite/mod.rs b/src/app/favorite/mod.rs deleted file mode 100644 index a451d09..0000000 --- a/src/app/favorite/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod api; -pub mod model; -pub mod response; -pub mod service; diff --git a/src/app/favorite/response.rs b/src/app/favorite/response.rs deleted file mode 100644 index ca699d6..0000000 --- a/src/app/favorite/response.rs +++ /dev/null @@ -1 +0,0 @@ -pub use crate::app::article::response::SingleArticleResponse; diff --git a/src/app/favorite/service.rs b/src/app/favorite/service.rs deleted file mode 100644 index 1243731..0000000 --- a/src/app/favorite/service.rs +++ /dev/null @@ -1,75 +0,0 @@ -use crate::app::article::model::{Article, FetchBySlugAndAuthorId}; -use crate::app::article::service::{fetch_article, FetchArticle}; -use crate::app::favorite::model::{CreateFavorite, DeleteFavorite, Favorite, FavoriteInfo}; -use crate::app::profile::model::Profile; -use crate::app::tag::model::Tag; -use crate::app::user::model::User; -use crate::error::AppError; -use diesel::pg::PgConnection; - -pub struct FavoriteService { - pub current_user: User, - pub article_title_slug: String, -} - -// TODO: move to User model -pub fn favorite( - conn: &mut PgConnection, - params: &FavoriteService, -) -> Result<(Article, Profile, FavoriteInfo, Vec), AppError> { - let article = Article::fetch_by_slug_and_author_id( - conn, - &FetchBySlugAndAuthorId { - slug: params.article_title_slug.to_owned(), - author_id: params.current_user.id, - }, - )?; - Favorite::create( - conn, - &CreateFavorite { - user_id: params.current_user.id, - article_id: article.id, - }, - )?; - let item = fetch_article( - conn, - &FetchArticle { - article_id: article.id, - current_user: params.current_user.to_owned(), - }, - )?; - Ok(item) -} - -pub struct UnfavoriteService { - pub current_user: User, - pub article_title_slug: String, -} - -pub fn unfavorite( - conn: &mut PgConnection, - params: &UnfavoriteService, -) -> Result<(Article, Profile, FavoriteInfo, Vec), AppError> { - let article = Article::fetch_by_slug_and_author_id( - conn, - &FetchBySlugAndAuthorId { - slug: params.article_title_slug.to_owned(), - author_id: params.current_user.id, - }, - )?; - Favorite::delete( - conn, - &DeleteFavorite { - user_id: params.current_user.id, - article_id: article.id, - }, - )?; - let item = fetch_article( - conn, - &FetchArticle { - article_id: article.id, - current_user: params.current_user.to_owned(), - }, - )?; - Ok(item) -} diff --git a/src/app/features/article/controllers.rs b/src/app/features/article/controllers.rs new file mode 100644 index 0000000..f1d2b69 --- /dev/null +++ b/src/app/features/article/controllers.rs @@ -0,0 +1,129 @@ +use super::{ + entities::{Article, DeleteArticle}, + presenters::{MultipleArticlesResponse, SingleArticleResponse}, + repositories::FetchFollowingArticlesRepositoryInput, + requests, + usecases::{ + CreateArticleUsecaseInput, DeleteArticleUsecaseInput, FetchArticlesListUsecaseInput, + UpdateArticleUsecaseInput, + }, +}; +use crate::app::drivers::middlewares::auth; +use crate::app::drivers::middlewares::state::AppState; +use crate::utils::api::ApiResponse; +use actix_web::{web, HttpRequest, HttpResponse}; +use serde::Deserialize; + +type ArticleTitleSlug = String; + +#[derive(Deserialize)] +pub struct ArticlesListQueryParameter { + tag: Option, + author: Option, + favorited: Option, + limit: Option, + offset: Option, +} + +pub async fn index( + state: web::Data, + params: web::Query, +) -> ApiResponse { + let offset = std::cmp::min(params.offset.to_owned().unwrap_or(0), 100); + let limit = params.limit.unwrap_or(20); + state + .di_container + .article_usecase + .fetch_articles_list(FetchArticlesListUsecaseInput { + tag: params.tag.clone(), + author: params.author.clone(), + favorited: params.favorited.clone(), + offset, + limit, + }) +} + +#[derive(Deserialize)] +pub struct FeedQueryParameter { + limit: Option, + offset: Option, +} + +pub async fn feed( + state: web::Data, + req: HttpRequest, + params: web::Query, +) -> ApiResponse { + let current_user = auth::get_current_user(&req)?; + let offset = std::cmp::min(params.offset.to_owned().unwrap_or(0), 100); + let limit = params.limit.unwrap_or(20); + state + .di_container + .article_usecase + .fetch_following_articles(current_user, offset, limit) +} + +pub async fn show(state: web::Data, path: web::Path) -> ApiResponse { + let article_title_slug = path.into_inner(); + state + .di_container + .article_usecase + .fetch_article_by_slug(article_title_slug) +} + +pub async fn create( + state: web::Data, + req: HttpRequest, + form: web::Json, +) -> ApiResponse { + let current_user = auth::get_current_user(&req)?; + state + .di_container + .article_usecase + .create(CreateArticleUsecaseInput { + title: form.article.title.clone(), + description: form.article.description.clone(), + body: form.article.body.clone(), + tag_name_list: form.article.tag_list.to_owned(), + current_user, + }) +} + +pub async fn update( + state: web::Data, + req: HttpRequest, + path: web::Path, + form: web::Json, +) -> ApiResponse { + let current_user = auth::get_current_user(&req)?; + let article_title_slug = path.into_inner(); + let title = form.article.title.clone(); + let description = form.article.description.clone(); + let body = form.article.body.clone(); + state + .di_container + .article_usecase + .update(UpdateArticleUsecaseInput { + current_user, + article_title_slug, + title, + description, + body, + }) +} + +pub async fn delete( + state: web::Data, + req: HttpRequest, + path: web::Path, +) -> ApiResponse { + let current_user = auth::get_current_user(&req)?; + let article_title_slug = path.into_inner(); + state + .di_container + .article_usecase + .delete(DeleteArticleUsecaseInput { + author_id: current_user.id, + slug: article_title_slug, + }) +} diff --git a/src/app/article/model.rs b/src/app/features/article/entities.rs similarity index 98% rename from src/app/article/model.rs rename to src/app/features/article/entities.rs index 65daa95..7aff2e6 100644 --- a/src/app/article/model.rs +++ b/src/app/features/article/entities.rs @@ -1,5 +1,5 @@ -use crate::app::favorite::model::Favorite; -use crate::app::user::model::User; +use crate::app::features::favorite::entities::Favorite; +use crate::app::features::user::entities::User; use crate::error::AppError; use crate::schema::articles; use crate::utils::converter; diff --git a/src/app/features/article/mod.rs b/src/app/features/article/mod.rs new file mode 100644 index 0000000..7f85232 --- /dev/null +++ b/src/app/features/article/mod.rs @@ -0,0 +1,6 @@ +pub mod controllers; +pub mod entities; +pub mod presenters; +pub mod repositories; +pub mod requests; +pub mod usecases; diff --git a/src/app/article/response.rs b/src/app/features/article/presenters.rs similarity index 75% rename from src/app/article/response.rs rename to src/app/features/article/presenters.rs index 3d7ea7d..76e171e 100644 --- a/src/app/article/response.rs +++ b/src/app/features/article/presenters.rs @@ -1,8 +1,9 @@ -use crate::app::article::model::Article; -use crate::app::favorite::model::FavoriteInfo; -use crate::app::profile::model::Profile; -use crate::app::tag::model::Tag; +use super::{entities::Article, repositories::ArticlesList}; +use crate::app::features::favorite::entities::FavoriteInfo; +use crate::app::features::profile::entities::Profile; +use crate::app::features::tag::entities::Tag; use crate::utils::date::Iso8601; +use actix_web::HttpResponse; use serde::{Deserialize, Serialize}; use std::convert::From; @@ -51,7 +52,6 @@ pub struct MultipleArticlesResponse { type ArticlesCount = i64; type Inner = ((Article, Profile, FavoriteInfo), Vec); -type ArticlesList = Vec; type Item = (ArticlesList, ArticlesCount); impl From for MultipleArticlesResponse { fn from((list, articles_count): (Vec, ArticleCount)) -> Self { @@ -119,3 +119,31 @@ pub struct AuthorContent { pub image: Option, pub following: bool, } + +pub trait ArticlePresenter: Send + Sync + 'static { + fn from_list_and_count(&self, list: ArticlesList, count: i64) -> HttpResponse; + fn from_item(&self, item: (Article, Profile, FavoriteInfo, Vec)) -> HttpResponse; + fn toHttpRes(&self) -> HttpResponse; +} + +#[derive(Clone)] +pub struct ArticlePresenterImpl {} +impl ArticlePresenterImpl { + pub fn new() -> Self { + Self {} + } +} +impl ArticlePresenter for ArticlePresenterImpl { + fn from_list_and_count(&self, list: ArticlesList, count: i64) -> HttpResponse { + let res = MultipleArticlesResponse::from((list, count)); + HttpResponse::Ok().json(res) + } + fn from_item(&self, item: (Article, Profile, FavoriteInfo, Vec)) -> HttpResponse { + let res = SingleArticleResponse::from(item); + HttpResponse::Ok().json(res) + } + fn toHttpRes(&self) -> HttpResponse { + let res = (); + HttpResponse::Ok().json(res) + } +} diff --git a/src/app/features/article/repositories.rs b/src/app/features/article/repositories.rs new file mode 100644 index 0000000..129ebdc --- /dev/null +++ b/src/app/features/article/repositories.rs @@ -0,0 +1,489 @@ +use super::entities::{Article, CreateArticle, DeleteArticle, UpdateArticle}; +use crate::app::features::favorite::entities::FavoriteInfo; +use crate::app::features::profile::entities::Profile; +use crate::app::features::tag::entities::{CreateTag, Tag}; +use crate::app::features::user::entities::User; +use crate::error::AppError; +use crate::utils::db::DbPool; +use diesel::PgConnection; +use uuid::Uuid; + +pub trait ArticleRepository: Send + Sync + 'static { + fn fetch_articles_list( + &self, + params: FetchArticlesListRepositoryInput, + ) -> Result<(ArticlesList, ArticlesCount), AppError>; + + fn fetch_article_by_slug( + &self, + article_title_slug: String, + ) -> Result; + + fn create( + &self, + params: CreateArticleRepositoryInput, + ) -> Result<(Article, Profile, FavoriteInfo, Vec), AppError>; + + fn delete(&self, input: DeleteArticleRepositoryInput) -> Result<(), AppError>; + + fn update( + &self, + input: UpdateArticleRepositoryInput, + ) -> Result<(Article, Profile, FavoriteInfo, Vec), AppError>; + + fn fetch_article_item( + &self, + input: &FetchArticleRepositoryInput, + ) -> Result<(Article, Profile, FavoriteInfo, Vec), AppError>; + + fn fetch_following_articles( + &self, + params: &FetchFollowingArticlesRepositoryInput, + ) -> Result<(ArticlesList, ArticlesCount), AppError>; +} +#[derive(Clone)] +pub struct ArticleRepositoryImpl { + pool: DbPool, +} + +impl ArticleRepositoryImpl { + pub fn new(pool: DbPool) -> Self { + Self { pool } + } + + fn create_tag_list( + conn: &mut PgConnection, + tag_name_list: &Option>, + article_id: &Uuid, + ) -> Result, AppError> { + let list = tag_name_list + .as_ref() + .map(|tag_name_list| { + let records = tag_name_list + .iter() + .map(|name| CreateTag { name, article_id }) + .collect(); + Tag::create_list(conn, records) + }) + .unwrap_or_else(|| Ok(vec![])); + list + } +} + +impl ArticleRepository for ArticleRepositoryImpl { + fn fetch_articles_list( + &self, + params: FetchArticlesListRepositoryInput, + ) -> Result<(ArticlesList, ArticlesCount), AppError> { + use crate::app::features::article::entities::Article; + use crate::app::features::favorite::entities::{Favorite, FavoriteInfo}; + use crate::app::features::follow::entities::Follow; + use crate::app::features::profile::entities::Profile; + use crate::app::features::tag::entities::Tag; + use crate::app::features::user::entities::User; + use crate::error::AppError; + use crate::schema::articles::dsl::*; + use crate::schema::{articles, tags, users}; + use diesel::pg::PgConnection; + use diesel::prelude::*; + use diesel::prelude::*; + // ==== + let conn = &mut self.pool.get()?; + + let query = { + let mut query = articles::table.inner_join(users::table).into_boxed(); + + if let Some(tag_name) = ¶ms.tag { + let ids = Tag::fetch_article_ids_by_name(conn, tag_name) + .expect("could not fetch tagged article ids."); // TODO: use ? or error handling + query = query.filter(articles::id.eq_any(ids)); + } + + if let Some(author_name) = ¶ms.author { + let ids = Article::fetch_ids_by_author_name(conn, author_name) + .expect("could not fetch authors id."); // TODO: use ? or error handling + query = query.filter(articles::id.eq_any(ids)); + } + + if let Some(username) = ¶ms.favorited { + let ids = Favorite::fetch_favorited_article_ids_by_username(conn, username) + .expect("could not fetch favorited articles id."); // TODO: use ? or error handling + + query = query.filter(articles::id.eq_any(ids)); + } + + query + }; + let articles_count = query + .select(diesel::dsl::count(articles::id)) + .first::(conn)?; + + let result = { + let query = { + let mut query = articles::table.inner_join(users::table).into_boxed(); + + if let Some(tag_name) = ¶ms.tag { + let ids = Tag::fetch_article_ids_by_name(conn, tag_name) + .expect("could not fetch tagged article ids."); // TODO: use ? or error handling + query = query.filter(articles::id.eq_any(ids)); + } + + if let Some(author_name) = ¶ms.author { + let ids = Article::fetch_ids_by_author_name(conn, author_name) + .expect("could not fetch authors id."); // TODO: use ? or error handling + query = query.filter(articles::id.eq_any(ids)); + } + + if let Some(username) = ¶ms.favorited { + let ids = Favorite::fetch_favorited_article_ids_by_username(conn, username) + .expect("could not fetch favorited articles id."); // TODO: use ? or error handling + + query = query.filter(articles::id.eq_any(ids)); + } + + query + }; + let article_and_user_list = + query + .offset(params.offset) + .limit(params.limit) + .load::<(Article, User)>(conn)?; + + let tags_list = { + let articles_list = article_and_user_list + .clone() + .into_iter() + .map(|(article, _)| article) + .collect::>(); + let tags_list = Tag::belonging_to(&articles_list) + .order(tags::name.asc()) + .load::(conn)?; + let tags_list: Vec> = tags_list.grouped_by(&articles_list); + tags_list + }; + + let favorites_count_list = { + let list: Result, _> = article_and_user_list + .clone() + .into_iter() + .map(|(article, _)| article.fetch_favorites_count(conn)) + .collect(); + + list? + }; + + article_and_user_list + .into_iter() + .zip(favorites_count_list) + .map(|((article, user), favorites_count)| { + ( + article, + Profile { + username: user.username, + bio: user.bio, + image: user.image, + following: false, // NOTE: because not authz + }, + FavoriteInfo { + is_favorited: false, // NOTE: because not authz + favorites_count, + }, + ) + }) + .zip(tags_list) + .collect::>() + }; + + Ok((result, articles_count)) + } + + fn fetch_article_by_slug( + &self, + article_title_slug: String, + ) -> Result { + let conn = &mut self.pool.get()?; + + let (article, author) = Article::fetch_by_slug_with_author(conn, &article_title_slug)?; + + let profile = author.fetch_profile(conn, &author.id)?; + + let tags_list = { + use diesel::prelude::*; + Tag::belonging_to(&article).load::(conn)? + }; + + let favorite_info = { + let is_favorited = article.is_favorited_by_user_id(conn, &author.id)?; + let favorites_count = article.fetch_favorites_count(conn)?; + FavoriteInfo { + is_favorited, + favorites_count, + } + }; + + Ok((article, profile, favorite_info, tags_list)) + } + + fn create( + &self, + params: CreateArticleRepositoryInput, + ) -> Result<(Article, Profile, FavoriteInfo, Vec), AppError> { + let conn = &mut self.pool.get()?; + + let article = Article::create( + conn, + &CreateArticle { + author_id: params.current_user.id, + slug: params.slug.clone(), + title: params.title.clone(), + description: params.description.clone(), + body: params.body.clone(), + }, + )?; + + let tag_list = Self::create_tag_list(conn, ¶ms.tag_name_list, &article.id)?; + + let profile = params + .current_user + .fetch_profile(conn, &article.author_id)?; + + let favorite_info = { + let is_favorited = article.is_favorited_by_user_id(conn, ¶ms.current_user.id)?; + let favorites_count = article.fetch_favorites_count(conn)?; + FavoriteInfo { + is_favorited, + favorites_count, + } + }; + + Ok((article, profile, favorite_info, tag_list)) + } + + fn delete(&self, input: DeleteArticleRepositoryInput) -> Result<(), AppError> { + let conn = &mut self.pool.get()?; + Article::delete( + conn, + &DeleteArticle { + slug: input.slug, + author_id: input.author_id, + }, + ) + } + + fn update( + &self, + input: UpdateArticleRepositoryInput, + ) -> Result<(Article, Profile, FavoriteInfo, Vec), AppError> { + let conn = &mut self.pool.get()?; + + let article = Article::update( + conn, + &input.article_title_slug, + &input.current_user.id, + &UpdateArticle { + slug: input.slug.to_owned(), + title: input.title.to_owned(), + description: input.description.to_owned(), + body: input.body.to_owned(), + }, + )?; + + let tag_list = Tag::fetch_by_article_id(conn, &article.id)?; + + let profile = input.current_user.fetch_profile(conn, &article.author_id)?; + + let favorite_info = { + let is_favorited = article.is_favorited_by_user_id(conn, &input.current_user.id)?; + let favorites_count = article.fetch_favorites_count(conn)?; + FavoriteInfo { + is_favorited, + favorites_count, + } + }; + + Ok((article, profile, favorite_info, tag_list)) + } + + fn fetch_article_item( + &self, + input: &FetchArticleRepositoryInput, + ) -> Result<(Article, Profile, FavoriteInfo, Vec), AppError> { + let conn = &mut self.pool.get()?; + let (article, author) = Article::find_with_author(conn, &input.article_id)?; + + let profile = input.current_user.fetch_profile(conn, &author.id)?; + + let favorite_info = { + let is_favorited = article.is_favorited_by_user_id(conn, &input.current_user.id)?; + let favorites_count = article.fetch_favorites_count(conn)?; + FavoriteInfo { + is_favorited, + favorites_count, + } + }; + + let tags_list = { + use diesel::prelude::*; + Tag::belonging_to(&article).load::(conn)? + }; + + Ok((article, profile, favorite_info, tags_list)) + } + + fn fetch_following_articles( + &self, + params: &FetchFollowingArticlesRepositoryInput, + ) -> Result<(ArticlesList, ArticlesCount), AppError> { + use crate::app::features::article::entities::Article; + use crate::app::features::favorite::entities::{Favorite, FavoriteInfo}; + use crate::app::features::follow::entities::Follow; + use crate::app::features::profile::entities::Profile; + use crate::app::features::tag::entities::Tag; + use crate::app::features::user::entities::User; + use crate::error::AppError; + use crate::schema::articles::dsl::*; + use crate::schema::follows; + use crate::schema::{articles, tags, users}; + use diesel::pg::PgConnection; + use diesel::prelude::*; + // == + let conn = &mut self.pool.get()?; + + let create_query = { + let ids = Follow::fetch_folowee_ids_by_follower_id(conn, ¶ms.current_user.id)?; + articles.filter(articles::author_id.eq_any(ids)) + }; + + let articles_list = { + let article_and_user_list = create_query + .to_owned() + .inner_join(users::table) + .limit(params.limit) + .offset(params.offset) + .order(articles::created_at.desc()) + .get_results::<(Article, User)>(conn)?; + + let tags_list = { + let articles_list = article_and_user_list + .clone() // TODO: avoid clone + .into_iter() + .map(|(article, _)| article) + .collect::>(); + + let tags_list = Tag::belonging_to(&articles_list).load::(conn)?; + let tags_list: Vec> = tags_list.grouped_by(&articles_list); + tags_list + }; + + let follows_list = { + let user_ids_list = article_and_user_list + .clone() // TODO: avoid clone + .into_iter() + .map(|(_, user)| user.id) + .collect::>(); + + let list = follows::table + .filter(Follow::with_follower(¶ms.current_user.id)) + .filter(follows::followee_id.eq_any(user_ids_list)) + .get_results::(conn)?; + + list.into_iter() + }; + + let favorites_count_list = { + let list: Result, _> = article_and_user_list + .clone() + .into_iter() + .map(|(article, _)| article.fetch_favorites_count(conn)) + .collect(); + + list? + }; + + let favorited_article_ids = params.current_user.fetch_favorited_article_ids(conn)?; + let is_favorited_by_me = |article: &Article| { + favorited_article_ids + .iter() + .copied() + .any(|_id| _id == article.id) + }; + + article_and_user_list + .into_iter() + .zip(favorites_count_list) + .map(|((article, user), favorites_count)| { + let following = follows_list.clone().any(|item| item.followee_id == user.id); + let is_favorited = is_favorited_by_me(&article); + ( + article, + Profile { + username: user.username, + bio: user.bio, + image: user.image, + following: following.to_owned(), + }, + FavoriteInfo { + is_favorited, + favorites_count, + }, + ) + }) + .zip(tags_list) + .collect::>() + }; + + let articles_count = create_query + .select(diesel::dsl::count(articles::id)) + .first::(conn)?; + + Ok((articles_list, articles_count)) + } +} + +pub struct CreateArticleRepositoryInput { + pub slug: String, + pub title: String, + pub description: String, + pub body: String, + pub tag_name_list: Option>, + pub current_user: User, +} + +pub struct DeleteArticleRepositoryInput { + pub slug: String, + pub author_id: Uuid, +} + +pub struct UpdateArticleRepositoryInput { + pub current_user: User, + pub article_title_slug: String, + pub slug: Option, + pub title: Option, + pub description: Option, + pub body: Option, +} + +pub type FetchArticleBySlugOutput = (Article, Profile, FavoriteInfo, Vec); + +pub struct FetchArticlesListRepositoryInput { + pub tag: Option, + pub author: Option, + pub favorited: Option, + pub offset: i64, + pub limit: i64, +} + +pub struct FetchArticleRepositoryInput { + pub article_id: Uuid, + pub current_user: User, +} + +pub struct FetchFollowingArticlesRepositoryInput { + pub current_user: User, + pub offset: i64, + pub limit: i64, +} + +type ArticlesCount = i64; +type ArticlesListInner = (Article, Profile, FavoriteInfo); +pub type ArticlesList = Vec<(ArticlesListInner, Vec)>; diff --git a/src/app/article/request.rs b/src/app/features/article/requests.rs similarity index 100% rename from src/app/article/request.rs rename to src/app/features/article/requests.rs diff --git a/src/app/features/article/usecases.rs b/src/app/features/article/usecases.rs new file mode 100644 index 0000000..6a97312 --- /dev/null +++ b/src/app/features/article/usecases.rs @@ -0,0 +1,151 @@ +use super::entities::Article; +use super::presenters::ArticlePresenter; +use super::repositories::{ + ArticleRepository, CreateArticleRepositoryInput, DeleteArticleRepositoryInput, + FetchArticlesListRepositoryInput, FetchFollowingArticlesRepositoryInput, + UpdateArticleRepositoryInput, +}; +use crate::app::features::user::entities::User; +use crate::error::AppError; +use actix_web::HttpResponse; +use std::sync::Arc; +use uuid::Uuid; + +#[derive(Clone)] +pub struct ArticleUsecase { + article_repository: Arc, + article_presenter: Arc, +} + +impl ArticleUsecase { + pub fn new( + article_repository: Arc, + article_presenter: Arc, + ) -> Self { + Self { + article_repository, + article_presenter, + } + } + + pub fn fetch_articles_list( + &self, + params: FetchArticlesListUsecaseInput, + ) -> Result { + let (list, count) = + self.article_repository + .fetch_articles_list(FetchArticlesListRepositoryInput { + tag: params.tag.clone(), + author: params.author.clone(), + favorited: params.favorited.clone(), + offset: params.offset, + limit: params.limit, + })?; + let res = self.article_presenter.from_list_and_count(list, count); + Ok(res) + } + + pub fn fetch_article_by_slug( + &self, + article_title_slug: String, + ) -> Result { + let article_title_slug = article_title_slug.clone(); + let result = self + .article_repository + .fetch_article_by_slug(article_title_slug)?; + let res = self.article_presenter.from_item(result); + Ok(res) + } + + pub fn fetch_following_articles( + &self, + user: User, + offset: i64, + limit: i64, + ) -> Result { + let (list, count) = self.article_repository.fetch_following_articles( + &FetchFollowingArticlesRepositoryInput { + current_user: user, + offset, + limit, + }, + )?; + let res = self.article_presenter.from_list_and_count(list, count); + Ok(res) + } + + pub fn create(&self, params: CreateArticleUsecaseInput) -> Result { + let slug = Article::convert_title_to_slug(¶ms.title); + let result = self + .article_repository + .create(CreateArticleRepositoryInput { + body: params.body, + current_user: params.current_user, + description: params.description, + tag_name_list: params.tag_name_list, + title: params.title, + slug, + })?; + let res = self.article_presenter.from_item(result); + Ok(res) + } + + pub fn delete(&self, input: DeleteArticleUsecaseInput) -> Result { + self.article_repository + .delete(DeleteArticleRepositoryInput { + slug: input.slug, + author_id: input.author_id, + })?; + let res = self.article_presenter.toHttpRes(); + Ok(res) + } + + pub fn update(&self, input: UpdateArticleUsecaseInput) -> Result { + let article_slug = &input + .title + .as_ref() + .map(|_title| Article::convert_title_to_slug(_title)); + let slug = article_slug.to_owned(); + let result = self + .article_repository + .update(UpdateArticleRepositoryInput { + current_user: input.current_user, + article_title_slug: input.article_title_slug, + slug, + title: input.title, + description: input.description, + body: input.body, + })?; + let res = self.article_presenter.from_item(result); + Ok(res) + } +} + +pub struct CreateArticleUsecaseInput { + pub title: String, + pub description: String, + pub body: String, + pub tag_name_list: Option>, + pub current_user: User, +} + +pub struct DeleteArticleUsecaseInput { + pub slug: String, + pub author_id: Uuid, +} + +pub struct UpdateArticleUsecaseInput { + pub current_user: User, + pub article_title_slug: String, + pub title: Option, + pub description: Option, + pub body: Option, +} + +pub struct FetchArticlesListUsecaseInput { + pub tag: Option, + pub author: Option, + pub favorited: Option, + pub offset: i64, + pub limit: i64, +} diff --git a/src/app/features/comment/controllers.rs b/src/app/features/comment/controllers.rs new file mode 100644 index 0000000..b731c73 --- /dev/null +++ b/src/app/features/comment/controllers.rs @@ -0,0 +1,46 @@ +use super::request; +use crate::app::drivers::middlewares::auth; +use crate::app::drivers::middlewares::state::AppState; +use crate::utils::api::ApiResponse; +use crate::utils::uuid; +use actix_web::{web, HttpRequest}; + +type ArticleIdSlug = String; +type CommentIdSlug = String; + +pub async fn index(state: web::Data, req: HttpRequest) -> ApiResponse { + let current_user = auth::get_current_user(&req).ok(); + state + .di_container + .comment_usecase + .fetch_comments_list(¤t_user) +} + +pub async fn create( + state: web::Data, + req: HttpRequest, + path: web::Path, + form: web::Json, +) -> ApiResponse { + let current_user = auth::get_current_user(&req)?; + let article_title_slug = path.into_inner(); + let body = form.comment.body.to_owned(); + state + .di_container + .comment_usecase + .create(body, article_title_slug, current_user) +} + +pub async fn delete( + state: web::Data, + req: HttpRequest, + path: web::Path<(ArticleIdSlug, CommentIdSlug)>, +) -> ApiResponse { + let current_user = auth::get_current_user(&req)?; + let (article_title_slug, comment_id) = path.into_inner(); + let comment_id = uuid::parse(&comment_id)?; + state + .di_container + .comment_usecase + .delete(&article_title_slug, comment_id, current_user.id) +} diff --git a/src/app/comment/model.rs b/src/app/features/comment/entities.rs similarity index 95% rename from src/app/comment/model.rs rename to src/app/features/comment/entities.rs index 949288c..1f83934 100644 --- a/src/app/comment/model.rs +++ b/src/app/features/comment/entities.rs @@ -1,5 +1,5 @@ -use crate::app::article::model::Article; -use crate::app::user::model::User; +use crate::app::features::article::entities::Article; +use crate::app::features::user::entities::User; use crate::error::AppError; use crate::schema::comments; use chrono::NaiveDateTime; diff --git a/src/app/features/comment/mod.rs b/src/app/features/comment/mod.rs new file mode 100644 index 0000000..8b19374 --- /dev/null +++ b/src/app/features/comment/mod.rs @@ -0,0 +1,6 @@ +pub mod controllers; +pub mod entities; +pub mod presenters; +pub mod repositories; +pub mod request; +pub mod usecases; diff --git a/src/app/comment/response.rs b/src/app/features/comment/presenters.rs similarity index 68% rename from src/app/comment/response.rs rename to src/app/features/comment/presenters.rs index 379f2cb..3be869f 100644 --- a/src/app/comment/response.rs +++ b/src/app/features/comment/presenters.rs @@ -1,6 +1,7 @@ -use crate::app::comment::model::Comment; -use crate::app::profile::model::Profile; +use crate::app::features::comment::entities::Comment; +use crate::app::features::profile::entities::Profile; use crate::utils::date::Iso8601; +use actix_web::HttpResponse; use serde::{Deserialize, Serialize}; use std::convert::From; use uuid::Uuid; @@ -76,3 +77,32 @@ pub struct InnerAuthor { pub image: Option, pub following: bool, } + +pub trait CommentPresenter: Send + Sync + 'static { + fn toHttpRes(&self) -> HttpResponse; + fn from_comment_and_profile(&self, item: (Comment, Profile)) -> HttpResponse; + fn from_comment_and_profile_list(&self, list: Vec<(Comment, Profile)>) -> HttpResponse; +} + +#[derive(Clone)] +pub struct CommentPresenterImpl {} +impl CommentPresenterImpl { + pub fn new() -> Self { + Self {} + } +} +impl CommentPresenter for CommentPresenterImpl { + fn toHttpRes(&self) -> HttpResponse { + HttpResponse::Ok().json("OK") + } + + fn from_comment_and_profile_list(&self, list: Vec<(Comment, Profile)>) -> HttpResponse { + let res = MultipleCommentsResponse::from(list); + HttpResponse::Ok().json(res) + } + + fn from_comment_and_profile(&self, item: (Comment, Profile)) -> HttpResponse { + let res = SingleCommentResponse::from(item); + HttpResponse::Ok().json(res) + } +} diff --git a/src/app/features/comment/repositories.rs b/src/app/features/comment/repositories.rs new file mode 100644 index 0000000..d2cce2a --- /dev/null +++ b/src/app/features/comment/repositories.rs @@ -0,0 +1,128 @@ +use super::entities::{Comment, CreateComment, DeleteComment}; +use crate::{ + app::features::{ + article::entities::{Article, FetchBySlugAndAuthorId}, + profile::entities::Profile, + user::entities::User, + }, + error::AppError, + utils::db::DbPool, +}; +use uuid::Uuid; + +pub trait CommentRepository: Send + Sync + 'static { + fn fetch_comments_list( + &self, + current_user: &Option, + ) -> Result, AppError>; + + fn create( + &self, + body: String, + article_title_slug: String, + author: User, + ) -> Result<(Comment, Profile), AppError>; + + fn delete( + &self, + article_title_slug: &str, + comment_id: Uuid, + author_id: Uuid, + ) -> Result<(), AppError>; +} + +#[derive(Clone)] +pub struct CommentRepositoryImpl { + pool: DbPool, +} + +impl CommentRepositoryImpl { + pub fn new(pool: DbPool) -> Self { + Self { pool } + } +} +impl CommentRepository for CommentRepositoryImpl { + fn fetch_comments_list( + &self, + current_user: &Option, + ) -> Result, AppError> { + let conn = &mut self.pool.get()?; + + let comments = { + use crate::schema::comments; + // use crate::schema::comments::dsl::*; + use crate::schema::users; + use diesel::prelude::*; + comments::table + .inner_join(users::table) + // .filter(comments::article_id.eq(article_id)) + .get_results::<(Comment, User)>(conn)? + }; + + let comments = comments + .iter() + .map(|(comment, user)| { + // TODO: avoid N+1. Write one query to fetch all data somehow. + let profile = user.to_profile(conn, current_user); + // Self::conver_user_to_profile(&ConverUserToProfile { user, current_user }); + + // TODO: avoid copy + (comment.to_owned(), profile) + }) + .collect::>(); + + Ok(comments) + } + + fn create( + &self, + body: String, + article_title_slug: String, + author: User, + ) -> Result<(Comment, Profile), AppError> { + let conn = &mut self.pool.get()?; + + let article = Article::fetch_by_slug_and_author_id( + conn, + &FetchBySlugAndAuthorId { + slug: article_title_slug.to_owned(), + author_id: author.id, + }, + )?; + let comment = Comment::create( + conn, + &CreateComment { + body: body.to_string(), + author_id: author.id, + article_id: article.id.to_owned(), + }, + )?; + let profile = author.fetch_profile(conn, &author.id)?; + Ok((comment, profile)) + } + + fn delete( + &self, + article_title_slug: &str, + comment_id: Uuid, + author_id: Uuid, + ) -> Result<(), AppError> { + let conn = &mut self.pool.get()?; + let article = Article::fetch_by_slug_and_author_id( + conn, + &FetchBySlugAndAuthorId { + slug: article_title_slug.to_owned(), + author_id, + }, + )?; + Comment::delete( + conn, + &DeleteComment { + comment_id, + article_id: article.id, + author_id, + }, + )?; + Ok(()) + } +} diff --git a/src/app/comment/request.rs b/src/app/features/comment/request.rs similarity index 100% rename from src/app/comment/request.rs rename to src/app/features/comment/request.rs diff --git a/src/app/features/comment/usecases.rs b/src/app/features/comment/usecases.rs new file mode 100644 index 0000000..d74d1dd --- /dev/null +++ b/src/app/features/comment/usecases.rs @@ -0,0 +1,56 @@ +use super::presenters::CommentPresenter; +use super::repositories::CommentRepository; +use crate::app::features::user::entities::User; +use crate::error::AppError; +use actix_web::HttpResponse; +use std::sync::Arc; +use uuid::Uuid; + +#[derive(Clone)] +pub struct CommentUsecase { + comment_repository: Arc, + comment_presenter: Arc, +} + +impl CommentUsecase { + pub fn new( + comment_repository: Arc, + comment_presenter: Arc, + ) -> Self { + Self { + comment_repository, + comment_presenter, + } + } + + pub fn fetch_comments_list(&self, user: &Option) -> Result { + let result = self.comment_repository.fetch_comments_list(user)?; + let res = self.comment_presenter.from_comment_and_profile_list(result); + Ok(res) + } + + pub fn create( + &self, + body: String, + article_title_slug: String, + author: User, + ) -> Result { + let result = self + .comment_repository + .create(body, article_title_slug, author)?; + let res = self.comment_presenter.from_comment_and_profile(result); + Ok(res) + } + + pub fn delete( + &self, + article_title_slug: &str, + comment_id: Uuid, + author_id: Uuid, + ) -> Result { + self.comment_repository + .delete(&article_title_slug, comment_id, author_id); + let res = self.comment_presenter.toHttpRes(); + Ok(res) + } +} diff --git a/src/app/features/favorite/controllers.rs b/src/app/features/favorite/controllers.rs new file mode 100644 index 0000000..925f2b6 --- /dev/null +++ b/src/app/features/favorite/controllers.rs @@ -0,0 +1,32 @@ +use crate::app::drivers::middlewares::auth; +use crate::app::drivers::middlewares::state::AppState; +use crate::utils::api::ApiResponse; +use actix_web::{web, HttpRequest}; + +type ArticleIdSlug = String; + +pub async fn favorite( + state: web::Data, + req: HttpRequest, + path: web::Path, +) -> ApiResponse { + let current_user = auth::get_current_user(&req)?; + let article_title_slug = path.into_inner(); + state + .di_container + .favorite_usecase + .favorite(current_user, article_title_slug) +} + +pub async fn unfavorite( + state: web::Data, + req: HttpRequest, + path: web::Path, +) -> ApiResponse { + let current_user = auth::get_current_user(&req)?; + let article_title_slug = path.into_inner(); + state + .di_container + .favorite_usecase + .unfavorite(current_user, article_title_slug) +} diff --git a/src/app/favorite/model.rs b/src/app/features/favorite/entities.rs similarity index 95% rename from src/app/favorite/model.rs rename to src/app/features/favorite/entities.rs index 01009b1..582f35a 100644 --- a/src/app/favorite/model.rs +++ b/src/app/features/favorite/entities.rs @@ -1,5 +1,5 @@ -use crate::app::article::model::Article; -use crate::app::user::model::User; +use crate::app::features::article::entities::Article; +use crate::app::features::user::entities::User; use crate::error::AppError; use crate::schema::favorites; use chrono::NaiveDateTime; diff --git a/src/app/features/favorite/mod.rs b/src/app/features/favorite/mod.rs new file mode 100644 index 0000000..9c2709f --- /dev/null +++ b/src/app/features/favorite/mod.rs @@ -0,0 +1,5 @@ +pub mod controllers; +pub mod entities; +pub mod presenters; +pub mod repositories; +pub mod usecases; diff --git a/src/app/features/favorite/presenters.rs b/src/app/features/favorite/presenters.rs new file mode 100644 index 0000000..86fe1a2 --- /dev/null +++ b/src/app/features/favorite/presenters.rs @@ -0,0 +1,27 @@ +use super::entities::FavoriteInfo; +use crate::app::features::article::entities::Article; +pub use crate::app::features::article::presenters::SingleArticleResponse; +use crate::app::features::profile::entities::Profile; +use crate::app::features::tag::entities::Tag; +use actix_web::HttpResponse; + +pub trait FavoritePresenter: Send + Sync + 'static { + fn complete(&self, item: (Article, Profile, FavoriteInfo, Vec)) -> HttpResponse; +} + +#[derive(Clone)] +pub struct FavoritePresenterImpl {} +impl FavoritePresenterImpl { + pub fn new() -> Self { + Self {} + } +} +impl FavoritePresenter for FavoritePresenterImpl { + fn complete( + &self, + (article, profile, favorite_info, tags_list): (Article, Profile, FavoriteInfo, Vec), + ) -> HttpResponse { + let res_model = SingleArticleResponse::from((article, profile, favorite_info, tags_list)); + HttpResponse::Ok().json(res_model) + } +} diff --git a/src/app/features/favorite/repositories.rs b/src/app/features/favorite/repositories.rs new file mode 100644 index 0000000..2553c89 --- /dev/null +++ b/src/app/features/favorite/repositories.rs @@ -0,0 +1,62 @@ +use super::entities::{CreateFavorite, DeleteFavorite, Favorite}; +use crate::app::features::article::entities::{Article, FetchBySlugAndAuthorId}; +use crate::app::features::user::entities::User; +use crate::error::AppError; +use crate::utils::db::DbPool; + +pub trait FavoriteRepository: Send + Sync + 'static { + fn favorite(&self, user: User, article_title_slug: String) -> Result; + fn unfavorite(&self, user: User, article_title_slug: String) -> Result; +} + +#[derive(Clone)] +pub struct FavoriteRepositoryImpl { + pool: DbPool, +} + +impl FavoriteRepositoryImpl { + pub fn new(pool: DbPool) -> Self { + Self { pool } + } +} +impl FavoriteRepository for FavoriteRepositoryImpl { + fn favorite(&self, user: User, article_title_slug: String) -> Result { + let conn = &mut self.pool.get()?; + + let article = Article::fetch_by_slug_and_author_id( + conn, + &FetchBySlugAndAuthorId { + slug: article_title_slug.to_owned(), + author_id: user.id, + }, + )?; + Favorite::create( + conn, + &CreateFavorite { + user_id: user.id, + article_id: article.id, + }, + )?; + + Ok(article) + } + + fn unfavorite(&self, user: User, article_title_slug: String) -> Result { + let conn = &mut self.pool.get()?; + let article = Article::fetch_by_slug_and_author_id( + conn, + &FetchBySlugAndAuthorId { + slug: article_title_slug.to_owned(), + author_id: user.id, + }, + )?; + Favorite::delete( + conn, + &DeleteFavorite { + user_id: user.id, + article_id: article.id, + }, + )?; + Ok(article) + } +} diff --git a/src/app/features/favorite/usecases.rs b/src/app/features/favorite/usecases.rs new file mode 100644 index 0000000..bb431ce --- /dev/null +++ b/src/app/features/favorite/usecases.rs @@ -0,0 +1,67 @@ +use super::presenters::FavoritePresenter; +use super::repositories::FavoriteRepository; +use crate::app::features::article::repositories::{ArticleRepository, FetchArticleRepositoryInput}; +use crate::app::features::user::entities::User; +use crate::error::AppError; +use actix_web::HttpResponse; +use std::sync::Arc; + +#[derive(Clone)] +pub struct FavoriteUsecase { + favorite_repository: Arc, + favorite_presenter: Arc, + article_repository: Arc, +} + +impl FavoriteUsecase { + pub fn new( + favorite_repository: Arc, + favorite_presenter: Arc, + article_repository: Arc, + ) -> Self { + Self { + favorite_repository, + favorite_presenter, + article_repository, + } + } + + pub fn favorite( + &self, + user: User, + article_title_slug: String, + ) -> Result { + let article = self + .favorite_repository + .favorite(user.clone(), article_title_slug)?; + + let result = self + .article_repository + .fetch_article_item(&FetchArticleRepositoryInput { + article_id: article.id, + current_user: user, + })?; + let res = self.favorite_presenter.complete(result); + Ok(res) + } + + pub fn unfavorite( + &self, + user: User, + article_title_slug: String, + ) -> Result { + let article = self + .favorite_repository + .unfavorite(user.clone(), article_title_slug)?; + + let result = self + .article_repository + .fetch_article_item(&FetchArticleRepositoryInput { + article_id: article.id, + current_user: user, + })?; + + let res = self.favorite_presenter.complete(result); + Ok(res) + } +} diff --git a/src/app/follow/model.rs b/src/app/features/follow/entities.rs similarity index 97% rename from src/app/follow/model.rs rename to src/app/features/follow/entities.rs index f31e992..89ddb1e 100644 --- a/src/app/follow/model.rs +++ b/src/app/features/follow/entities.rs @@ -1,4 +1,4 @@ -use crate::app::user::model::User; +use crate::app::features::user::entities::User; use crate::error::AppError; use crate::schema::follows; use chrono::NaiveDateTime; diff --git a/src/app/features/follow/mod.rs b/src/app/features/follow/mod.rs new file mode 100644 index 0000000..0b8f0b5 --- /dev/null +++ b/src/app/features/follow/mod.rs @@ -0,0 +1 @@ +pub mod entities; diff --git a/src/app/healthcheck/api.rs b/src/app/features/healthcheck/controllers.rs similarity index 100% rename from src/app/healthcheck/api.rs rename to src/app/features/healthcheck/controllers.rs diff --git a/src/app/features/healthcheck/mod.rs b/src/app/features/healthcheck/mod.rs new file mode 100644 index 0000000..f916674 --- /dev/null +++ b/src/app/features/healthcheck/mod.rs @@ -0,0 +1 @@ +pub mod controllers; diff --git a/src/app/features/mod.rs b/src/app/features/mod.rs new file mode 100644 index 0000000..4ef9b41 --- /dev/null +++ b/src/app/features/mod.rs @@ -0,0 +1,8 @@ +pub mod article; +pub mod comment; +pub mod favorite; +pub mod follow; +pub mod healthcheck; +pub mod profile; +pub mod tag; +pub mod user; diff --git a/src/app/features/profile/controllers.rs b/src/app/features/profile/controllers.rs new file mode 100644 index 0000000..628cee8 --- /dev/null +++ b/src/app/features/profile/controllers.rs @@ -0,0 +1,44 @@ +use crate::app::drivers::middlewares::{auth, state::AppState}; +use crate::utils::api::ApiResponse; +use actix_web::{web, HttpRequest}; + +type UsernameSlug = String; + +pub async fn show( + state: web::Data, + req: HttpRequest, + path: web::Path, +) -> ApiResponse { + let current_user = auth::get_current_user(&req)?; + let username = path.into_inner(); + state + .di_container + .profile_usecase + .show(¤t_user, &username) +} + +pub async fn follow( + state: web::Data, + req: HttpRequest, + path: web::Path, +) -> ApiResponse { + let current_user = auth::get_current_user(&req)?; + let target_username = path.into_inner(); + state + .di_container + .profile_usecase + .follow(¤t_user, &target_username) +} + +pub async fn unfollow( + state: web::Data, + req: HttpRequest, + path: web::Path, +) -> ApiResponse { + let current_user = auth::get_current_user(&req)?; + let target_username = path.into_inner(); + state + .di_container + .profile_usecase + .unfollow(¤t_user, &target_username) +} diff --git a/src/app/profile/model.rs b/src/app/features/profile/entities.rs similarity index 100% rename from src/app/profile/model.rs rename to src/app/features/profile/entities.rs diff --git a/src/app/features/profile/mod.rs b/src/app/features/profile/mod.rs new file mode 100644 index 0000000..9c2709f --- /dev/null +++ b/src/app/features/profile/mod.rs @@ -0,0 +1,5 @@ +pub mod controllers; +pub mod entities; +pub mod presenters; +pub mod repositories; +pub mod usecases; diff --git a/src/app/profile/response.rs b/src/app/features/profile/presenters.rs similarity index 56% rename from src/app/profile/response.rs rename to src/app/features/profile/presenters.rs index c7e3320..ebefafa 100644 --- a/src/app/profile/response.rs +++ b/src/app/features/profile/presenters.rs @@ -1,6 +1,8 @@ -use crate::app::profile::model::Profile as ProfileModel; +use super::entities::Profile as ProfileModel; +use actix_web::HttpResponse; use serde::{Deserialize, Serialize}; use std::convert::From; + #[derive(Deserialize, Serialize, Debug, Clone)] pub struct ProfileResponse { pub profile: ProfileContent, @@ -25,3 +27,21 @@ impl From for ProfileResponse { ProfileResponse { profile } } } + +pub trait ProfilePresenter: Send + Sync + 'static { + fn from_profile(&self, model: ProfileModel) -> HttpResponse; +} + +#[derive(Clone)] +pub struct ProfilePresenterImpl {} +impl ProfilePresenterImpl { + pub fn new() -> Self { + Self {} + } +} +impl ProfilePresenter for ProfilePresenterImpl { + fn from_profile(&self, model: ProfileModel) -> HttpResponse { + let res_model = ProfileResponse::from(model); + HttpResponse::Ok().json(res_model) + } +} diff --git a/src/app/features/profile/repositories.rs b/src/app/features/profile/repositories.rs new file mode 100644 index 0000000..a1ee8a7 --- /dev/null +++ b/src/app/features/profile/repositories.rs @@ -0,0 +1,30 @@ +use super::entities::Profile; +use crate::app::features::user::entities::User; +use crate::error::AppError; +use crate::utils::db::DbPool; + +pub trait ProfileRepository: Send + Sync + 'static { + fn fetch_by_name(&self, current_user: &User, username: &str) -> Result; +} + +#[derive(Clone)] +pub struct ProfileRepositoryImpl { + pool: DbPool, +} + +impl ProfileRepositoryImpl { + pub fn new(pool: DbPool) -> Self { + Self { pool } + } +} + +impl ProfileRepository for ProfileRepositoryImpl { + fn fetch_by_name(&self, current_user: &User, username: &str) -> Result { + let conn = &mut self.pool.get()?; + let profile = { + let followee = User::find_by_username(conn, username)?; + current_user.fetch_profile(conn, &followee.id)? + }; + Ok(profile) + } +} diff --git a/src/app/features/profile/usecases.rs b/src/app/features/profile/usecases.rs new file mode 100644 index 0000000..db6e262 --- /dev/null +++ b/src/app/features/profile/usecases.rs @@ -0,0 +1,55 @@ +use super::presenters::ProfilePresenter; +use super::repositories::ProfileRepository; +use crate::app::features::user::entities::User; +use crate::app::features::user::repositories::UserRepository; +use crate::error::AppError; +use actix_web::HttpResponse; +use std::sync::Arc; + +#[derive(Clone)] +pub struct ProfileUsecase { + user_repository: Arc, + profile_repository: Arc, + presenter: Arc, +} + +impl ProfileUsecase { + pub fn new( + profile_repository: Arc, + user_repository: Arc, + presenter: Arc, + ) -> Self { + Self { + profile_repository, + user_repository, + presenter, + } + } + + pub fn show(&self, current_user: &User, username: &str) -> Result { + let profile = self + .profile_repository + .fetch_by_name(current_user, username)?; + Ok(self.presenter.from_profile(profile)) + } + + pub fn follow( + &self, + current_user: &User, + target_username: &str, + ) -> Result { + let profile = self.user_repository.follow(current_user, target_username)?; + Ok(self.presenter.from_profile(profile)) + } + + pub fn unfollow( + &self, + current_user: &User, + target_username: &str, + ) -> Result { + let profile = self + .user_repository + .unfollow(current_user, target_username)?; + Ok(self.presenter.from_profile(profile)) + } +} diff --git a/src/app/features/tag/controllers.rs b/src/app/features/tag/controllers.rs new file mode 100644 index 0000000..edc4e69 --- /dev/null +++ b/src/app/features/tag/controllers.rs @@ -0,0 +1,8 @@ +extern crate serde_json; +use crate::app::drivers::middlewares::state::AppState; +use crate::utils::api::ApiResponse; +use actix_web::web; + +pub async fn index(state: web::Data) -> ApiResponse { + state.di_container.tag_usecase.list() +} diff --git a/src/app/tag/model.rs b/src/app/features/tag/entities.rs similarity index 98% rename from src/app/tag/model.rs rename to src/app/features/tag/entities.rs index c7dcd13..d1755a0 100644 --- a/src/app/tag/model.rs +++ b/src/app/features/tag/entities.rs @@ -1,4 +1,4 @@ -use crate::app::article::model::Article; +use crate::app::features::article::entities::Article; use crate::error::AppError; use crate::schema::tags; use chrono::NaiveDateTime; diff --git a/src/app/features/tag/mod.rs b/src/app/features/tag/mod.rs new file mode 100644 index 0000000..9c2709f --- /dev/null +++ b/src/app/features/tag/mod.rs @@ -0,0 +1,5 @@ +pub mod controllers; +pub mod entities; +pub mod presenters; +pub mod repositories; +pub mod usecases; diff --git a/src/app/features/tag/presenters.rs b/src/app/features/tag/presenters.rs new file mode 100644 index 0000000..9c70b31 --- /dev/null +++ b/src/app/features/tag/presenters.rs @@ -0,0 +1,34 @@ +use super::entities::Tag; +use actix_web::HttpResponse; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct TagsResponse { + // SPEC: https://gothinkster.github.io/realworld/docs/specs/backend-specs/endpoints#registration + pub tags: Vec, +} + +impl std::convert::From> for TagsResponse { + fn from(tags: Vec) -> Self { + let list = tags.iter().map(move |tag| tag.name.clone()).collect(); + TagsResponse { tags: list } + } +} + +pub trait TagPresenter: Send + Sync + 'static { + fn from_list(&self, list: Vec) -> HttpResponse; +} + +#[derive(Clone)] +pub struct TagPresenterImpl {} +impl TagPresenterImpl { + pub fn new() -> Self { + Self {} + } +} +impl TagPresenter for TagPresenterImpl { + fn from_list(&self, list: Vec) -> HttpResponse { + let res = TagsResponse::from(list); + HttpResponse::Ok().json(res) + } +} diff --git a/src/app/features/tag/repositories.rs b/src/app/features/tag/repositories.rs new file mode 100644 index 0000000..2b8b025 --- /dev/null +++ b/src/app/features/tag/repositories.rs @@ -0,0 +1,25 @@ +use super::entities::Tag; +use crate::error::AppError; +use crate::utils::db::DbPool; + +pub trait TagRepository: Send + Sync + 'static { + fn list(&self) -> Result, AppError>; +} + +#[derive(Clone)] +pub struct TagRepositoryImpl { + pool: DbPool, +} + +impl TagRepositoryImpl { + pub fn new(pool: DbPool) -> Self { + Self { pool } + } +} + +impl TagRepository for TagRepositoryImpl { + fn list(&self) -> Result, AppError> { + let conn = &mut self.pool.get()?; + Tag::fetch(conn) + } +} diff --git a/src/app/features/tag/usecases.rs b/src/app/features/tag/usecases.rs new file mode 100644 index 0000000..72f02c4 --- /dev/null +++ b/src/app/features/tag/usecases.rs @@ -0,0 +1,29 @@ +use super::presenters::TagPresenter; +use super::repositories::TagRepository; +use crate::error::AppError; +use actix_web::HttpResponse; +use std::sync::Arc; + +#[derive(Clone)] +pub struct TagUsecase { + tag_repository: Arc, + tag_presenter: Arc, +} + +impl TagUsecase { + pub fn new( + tag_repository: Arc, + tag_presenter: Arc, + ) -> Self { + Self { + tag_repository, + tag_presenter, + } + } + + pub fn list(&self) -> Result { + let list = self.tag_repository.list()?; + let res = self.tag_presenter.from_list(list); + Ok(res) + } +} diff --git a/src/app/features/user/controllers.rs b/src/app/features/user/controllers.rs new file mode 100644 index 0000000..b8e7a1c --- /dev/null +++ b/src/app/features/user/controllers.rs @@ -0,0 +1,44 @@ +use super::entities::UpdateUser; +use super::requests; +use crate::app::drivers::middlewares::auth; +use crate::app::drivers::middlewares::state::AppState; +use crate::utils::api::ApiResponse; +use actix_web::{web, HttpRequest}; + +pub async fn signin(state: web::Data, form: web::Json) -> ApiResponse { + state + .di_container + .user_usecase + .signin(&form.user.email, &form.user.password) +} + +pub async fn signup(state: web::Data, form: web::Json) -> ApiResponse { + state.di_container.user_usecase.signup( + &form.user.email, + &form.user.username, + &form.user.password, + ) +} + +pub async fn me(state: web::Data, req: HttpRequest) -> ApiResponse { + let current_user = auth::get_current_user(&req)?; + state.di_container.user_usecase.me(¤t_user) +} + +pub async fn update( + state: web::Data, + req: HttpRequest, + form: web::Json, +) -> ApiResponse { + let current_user = auth::get_current_user(&req)?; + state.di_container.user_usecase.update( + current_user.id, + UpdateUser { + email: form.user.email.clone(), + username: form.user.username.clone(), + password: form.user.password.clone(), + image: form.user.image.clone(), + bio: form.user.bio.clone(), + }, + ) +} diff --git a/src/app/user/model.rs b/src/app/features/user/entities.rs similarity index 79% rename from src/app/user/model.rs rename to src/app/features/user/entities.rs index 82f911a..5b7aa8f 100644 --- a/src/app/user/model.rs +++ b/src/app/features/user/entities.rs @@ -1,6 +1,6 @@ -use crate::app::favorite::model::Favorite; -use crate::app::follow::model::{CreateFollow, DeleteFollow, Follow}; -use crate::app::profile::model::Profile; +use crate::app::features::favorite::entities::Favorite; +use crate::app::features::follow::entities::Follow; +use crate::app::features::profile::entities::Profile; use crate::error::AppError; use crate::schema::users; use crate::utils::{hasher, token}; @@ -46,7 +46,7 @@ impl User { users::username.eq(username) } - fn by_username(username: &str) -> ByUsername + pub fn by_username(username: &str) -> ByUsername where DB: Backend, { @@ -125,46 +125,6 @@ impl User { Ok(user) } - pub fn follow(&self, conn: &mut PgConnection, username: &str) -> Result { - let t = Self::by_username(username); - let followee = t.first::(conn)?; - - Follow::create( - conn, - &CreateFollow { - follower_id: self.id, - followee_id: followee.id, - }, - )?; - - Ok(Profile { - username: self.username.clone(), - bio: self.bio.clone(), - image: self.image.clone(), - following: true, - }) - } - - pub fn unfollow(&self, conn: &mut PgConnection, username: &str) -> Result { - let t = Self::by_username(username); - let followee = t.first::(conn)?; - - Follow::delete( - conn, - &DeleteFollow { - followee_id: followee.id, - follower_id: self.id, - }, - )?; - - Ok(Profile { - username: self.username.clone(), - bio: self.bio.clone(), - image: self.image.clone(), - following: false, - }) - } - pub fn is_following(&self, conn: &mut PgConnection, followee_id: &Uuid) -> bool { use crate::schema::follows; let t = follows::table @@ -208,6 +168,21 @@ impl User { }; Ok(profile) } + + pub fn to_profile(&self, conn: &mut PgConnection, current_user: &Option) -> Profile { + let user = self; + let following = match current_user { + Some(current_user) => current_user.is_following(conn, &user.id), + None => false, + }; + + Profile { + username: user.username.to_owned(), + bio: user.bio.to_owned(), + image: user.image.to_owned(), + following, + } + } } #[derive(Insertable, Debug, Deserialize)] diff --git a/src/app/features/user/mod.rs b/src/app/features/user/mod.rs new file mode 100644 index 0000000..7f85232 --- /dev/null +++ b/src/app/features/user/mod.rs @@ -0,0 +1,6 @@ +pub mod controllers; +pub mod entities; +pub mod presenters; +pub mod repositories; +pub mod requests; +pub mod usecases; diff --git a/src/app/user/response.rs b/src/app/features/user/presenters.rs similarity index 59% rename from src/app/user/response.rs rename to src/app/features/user/presenters.rs index afeac3d..5967144 100644 --- a/src/app/user/response.rs +++ b/src/app/features/user/presenters.rs @@ -1,4 +1,5 @@ -use crate::app::user::model::User; +use crate::app::features::user::entities::User; +use actix_web::HttpResponse; use serde::{Deserialize, Serialize}; use std::convert::From; @@ -30,3 +31,20 @@ pub struct AuthUser { pub bio: Option, pub image: Option, } + +pub trait UserPresenter: Send + Sync + 'static { + fn from_user_and_token(&self, user: User, token: String) -> HttpResponse; +} +#[derive(Clone)] +pub struct UserPresenterImpl {} +impl UserPresenterImpl { + pub fn new() -> Self { + Self {} + } +} +impl UserPresenter for UserPresenterImpl { + fn from_user_and_token(&self, user: User, token: String) -> HttpResponse { + let res_model = UserResponse::from((user, token)); + HttpResponse::Ok().json(res_model) + } +} diff --git a/src/app/features/user/repositories.rs b/src/app/features/user/repositories.rs new file mode 100644 index 0000000..c45db55 --- /dev/null +++ b/src/app/features/user/repositories.rs @@ -0,0 +1,112 @@ +use super::entities::UpdateUser; +use crate::app::features::follow::entities::{CreateFollow, DeleteFollow, Follow}; +use crate::app::features::profile::entities::Profile; +use crate::app::features::user::entities::User; +use crate::error::AppError; +use crate::utils::db::DbPool; +use uuid::Uuid; + +type Token = String; + +pub trait UserRepository: Send + Sync + 'static { + fn me<'a>(&self, user: &'a User) -> Result<(&'a User, Token), AppError>; + fn signin(&self, email: &str, naive_password: &str) -> Result<(User, Token), AppError>; + fn signup( + &self, + email: &str, + username: &str, + naive_password: &str, + ) -> Result<(User, Token), AppError>; + fn follow(&self, current_user: &User, target_username: &str) -> Result; + fn unfollow(&self, current_user: &User, target_username: &str) -> Result; + fn update(&self, user_id: Uuid, changeset: UpdateUser) -> Result<(User, Token), AppError>; +} + +#[derive(Clone)] +pub struct UserRepositoryImpl { + pool: DbPool, +} + +impl UserRepositoryImpl { + pub fn new(pool: DbPool) -> Self { + Self { pool } + } +} + +impl UserRepository for UserRepositoryImpl { + fn me<'a>(&self, current_user: &'a User) -> Result<(&'a User, Token), AppError> { + let token = current_user.generate_token()?; + Ok((current_user, token)) + } + + fn signin(&self, email: &str, naive_password: &str) -> Result<(User, Token), AppError> { + let conn = &mut self.pool.get()?; + User::signin(conn, email, naive_password) + } + + fn signup( + &self, + email: &str, + username: &str, + naive_password: &str, + ) -> Result<(User, Token), AppError> { + let conn = &mut self.pool.get()?; + User::signup(conn, email, username, naive_password) + } + + fn follow(&self, current_user: &User, target_username: &str) -> Result { + let conn = &mut self.pool.get()?; + let t = User::by_username(target_username); + + let followee = { + use diesel::prelude::*; + t.first::(conn)? + }; + + Follow::create( + conn, + &CreateFollow { + follower_id: current_user.id, + followee_id: followee.id, + }, + )?; + + Ok(Profile { + username: current_user.username.clone(), + bio: current_user.bio.clone(), + image: current_user.image.clone(), + following: true, + }) + } + + fn unfollow(&self, current_user: &User, target_username: &str) -> Result { + let conn = &mut self.pool.get()?; + let t = User::by_username(target_username); + let followee = { + use diesel::prelude::*; + t.first::(conn)? + }; + + Follow::delete( + conn, + &DeleteFollow { + followee_id: followee.id, + follower_id: current_user.id, + }, + )?; + + Ok(Profile { + username: current_user.username.clone(), + bio: current_user.bio.clone(), + image: current_user.image.clone(), + following: false, + }) + } + + fn update(&self, user_id: Uuid, changeset: UpdateUser) -> Result<(User, Token), AppError> { + let conn = &mut self.pool.get()?; + let new_user = User::update(conn, user_id, changeset)?; + let token = &new_user.generate_token()?; + Ok((new_user, token.clone())) + } +} diff --git a/src/app/user/request.rs b/src/app/features/user/requests.rs similarity index 100% rename from src/app/user/request.rs rename to src/app/features/user/requests.rs diff --git a/src/app/features/user/usecases.rs b/src/app/features/user/usecases.rs new file mode 100644 index 0000000..9f5c795 --- /dev/null +++ b/src/app/features/user/usecases.rs @@ -0,0 +1,54 @@ +use super::entities::{UpdateUser, User}; +use super::presenters::UserPresenter; +use super::repositories::UserRepository; +use crate::error::AppError; +use actix_web::HttpResponse; +use std::sync::Arc; +use uuid::Uuid; + +#[derive(Clone)] +pub struct UserUsecase { + user_repository: Arc, + user_presenter: Arc, +} + +impl UserUsecase { + pub fn new( + user_repository: Arc, + user_presenter: Arc, + ) -> Self { + Self { + user_repository, + user_presenter, + } + } + + pub fn signin(&self, email: &str, password: &str) -> Result { + let (user, token) = self.user_repository.signin(email, password)?; + let res = self.user_presenter.from_user_and_token(user, token); + Ok(res) + } + + pub fn signup( + &self, + email: &str, + username: &str, + password: &str, + ) -> Result { + let (user, token) = self.user_repository.signup(email, username, password)?; + let res = self.user_presenter.from_user_and_token(user, token); + Ok(res) + } + + pub fn me(&self, current_user: &User) -> Result { + let (user, token) = self.user_repository.me(current_user)?; + let res = self.user_presenter.from_user_and_token(user.clone(), token); + Ok(res) + } + + pub fn update(&self, user_id: Uuid, changeset: UpdateUser) -> Result { + let (new_user, token) = self.user_repository.update(user_id, changeset)?; + let res = self.user_presenter.from_user_and_token(new_user, token); + Ok(res) + } +} diff --git a/src/app/follow/mod.rs b/src/app/follow/mod.rs deleted file mode 100644 index 65880be..0000000 --- a/src/app/follow/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod model; diff --git a/src/app/healthcheck/mod.rs b/src/app/healthcheck/mod.rs deleted file mode 100644 index e5fdf85..0000000 --- a/src/app/healthcheck/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod api; diff --git a/src/app/mod.rs b/src/app/mod.rs index d8e1fa0..710b127 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,8 +1,2 @@ -pub mod article; -pub mod comment; -pub mod favorite; -pub mod follow; -pub mod profile; -pub mod tag; -pub mod user; -pub mod healthcheck; \ No newline at end of file +pub mod drivers; +pub mod features; diff --git a/src/app/profile/api.rs b/src/app/profile/api.rs deleted file mode 100644 index 073ec35..0000000 --- a/src/app/profile/api.rs +++ /dev/null @@ -1,52 +0,0 @@ -use super::response::ProfileResponse; -use super::service; -use crate::middleware::{auth, state::AppState}; -use crate::utils::api::ApiResponse; -use actix_web::{web, HttpRequest, HttpResponse}; - -type UsernameSlug = String; - -pub async fn show( - state: web::Data, - req: HttpRequest, - path: web::Path, -) -> ApiResponse { - let conn = &mut state.get_conn()?; - let current_user = auth::get_current_user(&req)?; - let _username = path.into_inner(); - let profile = service::fetch_by_name( - conn, - &service::FetchProfileByName { - current_user, - username: _username, - }, - )?; - let res = ProfileResponse::from(profile); - Ok(HttpResponse::Ok().json(res)) -} - -pub async fn follow( - state: web::Data, - req: HttpRequest, - path: web::Path, -) -> ApiResponse { - let conn = &mut state.get_conn()?; - let current_user = auth::get_current_user(&req)?; - let username = path.into_inner(); - let profile = current_user.follow(conn, &username)?; - let res = ProfileResponse::from(profile); - Ok(HttpResponse::Ok().json(res)) -} - -pub async fn unfollow( - state: web::Data, - req: HttpRequest, - path: web::Path, -) -> ApiResponse { - let conn = &mut state.get_conn()?; - let current_user = auth::get_current_user(&req)?; - let username = path.into_inner(); - let profile = current_user.unfollow(conn, &username)?; - let res = ProfileResponse::from(profile); - Ok(HttpResponse::Ok().json(res)) -} diff --git a/src/app/profile/mod.rs b/src/app/profile/mod.rs deleted file mode 100644 index a451d09..0000000 --- a/src/app/profile/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod api; -pub mod model; -pub mod response; -pub mod service; diff --git a/src/app/profile/service.rs b/src/app/profile/service.rs deleted file mode 100644 index fcfbb81..0000000 --- a/src/app/profile/service.rs +++ /dev/null @@ -1,42 +0,0 @@ -use super::model::Profile; -use crate::app::user::model::User; -use crate::error::AppError; -use diesel::pg::PgConnection; - -pub struct FetchProfileByName { - pub current_user: User, - pub username: String, -} - -pub fn fetch_by_name( - conn: &mut PgConnection, - FetchProfileByName { - current_user, - username, - }: &FetchProfileByName, -) -> Result { - let profile = { - let followee = User::find_by_username(conn, username)?; - current_user.fetch_profile(conn, &followee.id)? - }; - Ok(profile) -} - -pub struct ConverUserToProfile<'a> { - pub user: &'a User, - pub current_user: &'a Option, -} - -pub fn conver_user_to_profile(conn: &mut PgConnection, params: &ConverUserToProfile) -> Profile { - let following = match params.current_user.as_ref() { - Some(current_user) => current_user.is_following(conn, ¶ms.user.id), - None => false, - }; - - Profile { - username: params.user.username.to_owned(), - bio: params.user.bio.to_owned(), - image: params.user.image.to_owned(), - following, - } -} diff --git a/src/app/tag/api.rs b/src/app/tag/api.rs deleted file mode 100644 index 8955b91..0000000 --- a/src/app/tag/api.rs +++ /dev/null @@ -1,13 +0,0 @@ -extern crate serde_json; -use super::model::Tag; -use super::response::TagsResponse; -use crate::middleware::state::AppState; -use crate::utils::api::ApiResponse; -use actix_web::{web, HttpResponse}; - -pub async fn index(state: web::Data) -> ApiResponse { - let conn = &mut state.get_conn()?; - let list = Tag::fetch(conn)?; - let res = TagsResponse::from(list); - Ok(HttpResponse::Ok().json(res)) -} diff --git a/src/app/tag/mod.rs b/src/app/tag/mod.rs deleted file mode 100644 index 7e6b406..0000000 --- a/src/app/tag/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod api; -pub mod model; -pub mod response; diff --git a/src/app/tag/response.rs b/src/app/tag/response.rs deleted file mode 100644 index d9ba5de..0000000 --- a/src/app/tag/response.rs +++ /dev/null @@ -1,15 +0,0 @@ -use super::model::Tag; -use serde::{Deserialize, Serialize}; - -#[derive(Deserialize, Serialize, Debug, Clone)] -pub struct TagsResponse { - // SPEC: https://gothinkster.github.io/realworld/docs/specs/backend-specs/endpoints#registration - pub tags: Vec, -} - -impl std::convert::From> for TagsResponse { - fn from(tags: Vec) -> Self { - let list = tags.iter().map(move |tag| tag.name.clone()).collect(); - TagsResponse { tags: list } - } -} diff --git a/src/app/user/api.rs b/src/app/user/api.rs deleted file mode 100644 index f544240..0000000 --- a/src/app/user/api.rs +++ /dev/null @@ -1,55 +0,0 @@ -use super::model::{UpdateUser, User}; -use super::{request, response::UserResponse}; -use crate::middleware::auth; -use crate::middleware::state::AppState; -use crate::utils::api::ApiResponse; -use actix_web::{web, HttpRequest, HttpResponse}; - -pub async fn signin(state: web::Data, form: web::Json) -> ApiResponse { - let conn = &mut state.get_conn()?; - let (user, token) = User::signin(conn, &form.user.email, &form.user.password)?; - let res = UserResponse::from((user, token)); - Ok(HttpResponse::Ok().json(res)) -} - -pub async fn signup(state: web::Data, form: web::Json) -> ApiResponse { - let conn = &mut state.get_conn()?; - let (user, token) = User::signup( - conn, - &form.user.email, - &form.user.username, - &form.user.password, - )?; - let res = UserResponse::from((user, token)); - Ok(HttpResponse::Ok().json(res)) -} - -pub async fn me(req: HttpRequest) -> ApiResponse { - let user = auth::get_current_user(&req)?; - let token = user.generate_token()?; - let res = UserResponse::from((user, token)); - Ok(HttpResponse::Ok().json(res)) -} - -pub async fn update( - state: web::Data, - req: HttpRequest, - form: web::Json, -) -> ApiResponse { - let conn = &mut state.get_conn()?; - let current_user = auth::get_current_user(&req)?; - let user = User::update( - conn, - current_user.id, - UpdateUser { - email: form.user.email.clone(), - username: form.user.username.clone(), - password: form.user.password.clone(), - image: form.user.image.clone(), - bio: form.user.bio.clone(), - }, - )?; - let token = &user.generate_token()?; - let res = UserResponse::from((user, token.to_string())); - Ok(HttpResponse::Ok().json(res)) -} diff --git a/src/app/user/mod.rs b/src/app/user/mod.rs deleted file mode 100644 index 27976bc..0000000 --- a/src/app/user/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod api; -pub mod model; -pub mod request; -pub mod response; diff --git a/src/main.rs b/src/main.rs index fa2d998..c662ebf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,8 +9,6 @@ use actix_web::{App, HttpServer}; mod app; mod constants; mod error; -mod middleware; -mod routes; mod schema; mod utils; @@ -22,16 +20,17 @@ async fn main() -> std::io::Result<()> { let state = { let pool = utils::db::establish_connection(); - middleware::state::AppState { pool } + use app::drivers::middlewares::state::AppState; + AppState::new(pool) }; HttpServer::new(move || { App::new() .wrap(Logger::default()) .app_data(actix_web::web::Data::new(state.clone())) - .wrap(middleware::cors::cors()) - .wrap(middleware::auth::Authentication) - .configure(routes::api) + .wrap(app::drivers::middlewares::cors::cors()) + .wrap(app::drivers::middlewares::auth::Authentication) + .configure(app::drivers::routes::api) }) .bind(constants::BIND)? .run() diff --git a/src/routes.rs b/src/routes.rs deleted file mode 100644 index 5825366..0000000 --- a/src/routes.rs +++ /dev/null @@ -1,53 +0,0 @@ -use crate::app; -use actix_web::web; -use actix_web::web::{delete, get, post, put}; - -pub fn api(cfg: &mut web::ServiceConfig) { - cfg.service( - web::scope("/api") - .service(web::scope("/healthcheck").route("", get().to(app::healthcheck::api::index))) - .service(web::scope("/tags").route("", get().to(app::tag::api::index))) - .service( - web::scope("/users") - .route("/login", post().to(app::user::api::signin)) - .route("", post().to(app::user::api::signup)), - ) - .service( - web::scope("/user") - .route("", get().to(app::user::api::me)) - .route("", put().to(app::user::api::update)), - ) - .service( - web::scope("/profiles") - .route("/{username}", get().to(app::profile::api::show)) - .route("/{username}/follow", post().to(app::profile::api::follow)) - .route( - "/{username}/follow", - delete().to(app::profile::api::unfollow), - ), - ) - .service( - web::scope("/articles") - .route("/feed", get().to(app::article::api::feed)) - .route("", get().to(app::article::api::index)) - .route("", post().to(app::article::api::create)) - .service( - web::scope("/{article_title_slug}") - .route("", get().to(app::article::api::show)) - .route("", put().to(app::article::api::update)) - .route("", delete().to(app::article::api::delete)) - .service( - web::scope("/favorite") - .route("", post().to(app::favorite::api::favorite)) - .route("", delete().to(app::favorite::api::unfavorite)), - ) - .service( - web::scope("/comments") - .route("", get().to(app::comment::api::index)) - .route("", post().to(app::comment::api::create)) - .route("/{comment_id}", delete().to(app::comment::api::delete)), - ), - ), - ), - ); -} diff --git a/src/utils/di.rs b/src/utils/di.rs new file mode 100644 index 0000000..eaa9e58 --- /dev/null +++ b/src/utils/di.rs @@ -0,0 +1,146 @@ +use crate::app::features::article::presenters::ArticlePresenterImpl; +use crate::app::features::article::repositories::ArticleRepositoryImpl; +use crate::app::features::article::usecases::ArticleUsecase; +use crate::app::features::comment::presenters::CommentPresenterImpl; +use crate::app::features::comment::repositories::CommentRepositoryImpl; +use crate::app::features::comment::usecases::CommentUsecase; +use crate::app::features::favorite::presenters::FavoritePresenterImpl; +use crate::app::features::favorite::repositories::FavoriteRepositoryImpl; +use crate::app::features::favorite::usecases::FavoriteUsecase; +use crate::app::features::profile::presenters::ProfilePresenterImpl; +use crate::app::features::profile::repositories::ProfileRepositoryImpl; +use crate::app::features::profile::usecases::ProfileUsecase; +use crate::app::features::tag::presenters::TagPresenterImpl; +use crate::app::features::tag::repositories::TagRepositoryImpl; +use crate::app::features::tag::usecases::TagUsecase; +use crate::app::features::user::presenters::UserPresenterImpl; +use crate::app::features::user::repositories::UserRepositoryImpl; +use crate::app::features::user::usecases::UserUsecase; +use std::sync::Arc; + +use crate::utils::db::DbPool; + +#[derive(Clone)] +pub struct DiContainer { + /** + * User + */ + pub user_repository: UserRepositoryImpl, + pub user_usecase: UserUsecase, + pub user_presenter: UserPresenterImpl, + + /** + * Profile + */ + pub profile_repository: ProfileRepositoryImpl, + pub profile_presenter: ProfilePresenterImpl, + pub profile_usecase: ProfileUsecase, + + /** + * Favorite + */ + pub favorite_repository: FavoriteRepositoryImpl, + pub favorite_presenter: FavoritePresenterImpl, + pub favorite_usecase: FavoriteUsecase, + + /** + * Article + */ + pub article_repository: ArticleRepositoryImpl, + pub article_presenter: ArticlePresenterImpl, + pub article_usecase: ArticleUsecase, + + /** + * Tag + */ + pub tag_repository: TagRepositoryImpl, + pub tag_presenter: TagPresenterImpl, + pub tag_usecase: TagUsecase, + + /** + * Comment + */ + pub comment_repository: CommentRepositoryImpl, + pub comment_presenter: CommentPresenterImpl, + pub comment_usecase: CommentUsecase, +} + +impl DiContainer { + pub fn new(pool: &DbPool) -> Self { + // Repository + let user_repository = UserRepositoryImpl::new(pool.clone()); + let profile_repository = ProfileRepositoryImpl::new(pool.clone()); + let favorite_repository = FavoriteRepositoryImpl::new(pool.clone()); + let article_repository = ArticleRepositoryImpl::new(pool.clone()); + let tag_repository = TagRepositoryImpl::new(pool.clone()); + let comment_repository = CommentRepositoryImpl::new(pool.clone()); + + // Presenter + let user_presenter = UserPresenterImpl::new(); + let profile_presenter = ProfilePresenterImpl::new(); + let favorite_presenter = FavoritePresenterImpl::new(); + let article_presenter = ArticlePresenterImpl::new(); + let tag_presenter = TagPresenterImpl::new(); + let comment_presenter = CommentPresenterImpl::new(); + + // Usecase + let user_usecase = UserUsecase::new( + Arc::new(user_repository.clone()), + Arc::new(user_presenter.clone()), + ); + let profile_usecase = ProfileUsecase::new( + Arc::new(profile_repository.clone()), + Arc::new(user_repository.clone()), + Arc::new(profile_presenter.clone()), + ); + let favorite_usecase = FavoriteUsecase::new( + Arc::new(favorite_repository.clone()), + Arc::new(favorite_presenter.clone()), + Arc::new(article_repository.clone()), + ); + let article_usecase = ArticleUsecase::new( + Arc::new(article_repository.clone()), + Arc::new(article_presenter.clone()), + ); + let tag_usecase = TagUsecase::new( + Arc::new(tag_repository.clone()), + Arc::new(tag_presenter.clone()), + ); + let comment_usecase = CommentUsecase::new( + Arc::new(comment_repository.clone()), + Arc::new(comment_presenter.clone()), + ); + + Self { + // User + user_repository, + user_usecase, + user_presenter, + + // Profile + profile_presenter, + profile_repository, + profile_usecase, + + // Favorite + favorite_repository, + favorite_presenter, + favorite_usecase, + + // Article + article_repository, + article_presenter, + article_usecase, + + // Tag + tag_repository, + tag_presenter, + tag_usecase, + + // Comment + comment_repository, + comment_presenter, + comment_usecase, + } + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index a3f00b1..5c0f2cf 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -2,6 +2,7 @@ pub mod api; pub mod converter; pub mod date; pub mod db; +pub mod di; pub mod hasher; pub mod token; pub mod uuid;