BLOG-104 Implement CRUD functionality for Posts #108

Merged
squid merged 5 commits from BLOG-104_post_create_and_update_routes into main 2025-08-02 14:35:27 +08:00
18 changed files with 452 additions and 15 deletions
Showing only changes of commit 27c23367ad - Show all commits

View File

@ -1,9 +1,11 @@
pub mod color_request_dto;
pub mod color_response_dto;
pub mod create_label_request_dto;
pub mod create_post_request_dto;
pub mod label_response_dto;
pub mod post_controller;
pub mod post_info_query_dto;
pub mod post_info_response_dto;
pub mod post_response_dto;
pub mod update_label_request_dto;
pub mod update_post_request_dto;

View File

@ -0,0 +1,37 @@
use chrono::{DateTime, Utc};
use serde::Deserialize;
use utoipa::ToSchema;
use crate::domain::entity::{post::Post, post_info::PostInfo};
#[derive(Deserialize, ToSchema, Clone)]
pub struct CreatePostRequestDto {
pub title: String,
pub description: String,
pub preview_image_url: String,
pub content: String,
pub label_ids: Vec<i32>,
#[schema(required)]
pub published_time: Option<i64>,
}
impl CreatePostRequestDto {
pub fn into_entity(self) -> Post {
Post {
id: -1,
info: PostInfo {
id: -1,
title: self.title,
description: self.description,
preview_image_url: self.preview_image_url,
labels: Vec::new(),
published_time: self
.published_time
.map(|micros| DateTime::<Utc>::from_timestamp_micros(micros))
.flatten(),
},
content: self.content,
}
}
}

View File

@ -4,16 +4,19 @@ use async_trait::async_trait;
use crate::{
adapter::delivery::{
create_label_request_dto::CreateLabelRequestDto, post_info_query_dto::PostQueryDto,
create_label_request_dto::CreateLabelRequestDto,
create_post_request_dto::CreatePostRequestDto, post_info_query_dto::PostQueryDto,
update_label_request_dto::UpdateLabelRequestDto,
update_post_request_dto::UpdatePostRequestDto,
},
application::{
error::post_error::PostError,
use_case::{
create_label_use_case::CreateLabelUseCase,
create_label_use_case::CreateLabelUseCase, create_post_use_case::CreatePostUseCase,
get_all_labels_use_case::GetAllLabelsUseCase,
get_all_post_info_use_case::GetAllPostInfoUseCase,
get_full_post_use_case::GetFullPostUseCase, update_label_use_case::UpdateLabelUseCase,
update_post_use_case::UpdatePostUseCase,
},
},
};
@ -32,6 +35,14 @@ pub trait PostController: Send + Sync {
async fn get_post_by_id(&self, id: i32) -> Result<PostResponseDto, PostError>;
async fn create_post(&self, post: CreatePostRequestDto) -> Result<PostResponseDto, PostError>;
async fn update_post(
&self,
id: i32,
post: UpdatePostRequestDto,
) -> Result<PostResponseDto, PostError>;
async fn create_label(
&self,
label: CreateLabelRequestDto,
@ -49,6 +60,8 @@ pub trait PostController: Send + Sync {
pub struct PostControllerImpl {
get_all_post_info_use_case: Arc<dyn GetAllPostInfoUseCase>,
get_full_post_use_case: Arc<dyn GetFullPostUseCase>,
create_post_use_case: Arc<dyn CreatePostUseCase>,
update_post_use_case: Arc<dyn UpdatePostUseCase>,
create_label_use_case: Arc<dyn CreateLabelUseCase>,
update_label_use_case: Arc<dyn UpdateLabelUseCase>,
get_all_labels_use_case: Arc<dyn GetAllLabelsUseCase>,
@ -58,6 +71,8 @@ impl PostControllerImpl {
pub fn new(
get_all_post_info_use_case: Arc<dyn GetAllPostInfoUseCase>,
get_full_post_use_case: Arc<dyn GetFullPostUseCase>,
create_post_use_case: Arc<dyn CreatePostUseCase>,
update_post_use_case: Arc<dyn UpdatePostUseCase>,
create_label_use_case: Arc<dyn CreateLabelUseCase>,
update_label_use_case: Arc<dyn UpdateLabelUseCase>,
get_all_labels_use_case: Arc<dyn GetAllLabelsUseCase>,
@ -65,6 +80,8 @@ impl PostControllerImpl {
Self {
get_all_post_info_use_case,
get_full_post_use_case,
create_post_use_case,
update_post_use_case,
create_label_use_case,
update_label_use_case,
get_all_labels_use_case,
@ -136,4 +153,31 @@ impl PostController for PostControllerImpl {
.collect()
})
}
async fn create_post(&self, post: CreatePostRequestDto) -> Result<PostResponseDto, PostError> {
let label_ids = post.label_ids.clone();
let post_entity = post.into_entity();
let id = self
.create_post_use_case
.execute(post_entity, &label_ids)
.await?;
self.get_post_by_id(id).await
}
async fn update_post(
&self,
id: i32,
post: UpdatePostRequestDto,
) -> Result<PostResponseDto, PostError> {
let label_ids = post.label_ids.clone();
let post_entity = post.into_entity(id);
self.update_post_use_case
.execute(post_entity, &label_ids)
.await?;
self.get_post_by_id(id).await
}
}

View File

@ -0,0 +1,37 @@
use chrono::{DateTime, Utc};
use serde::Deserialize;
use utoipa::ToSchema;
use crate::domain::entity::{post::Post, post_info::PostInfo};
#[derive(Deserialize, ToSchema, Clone)]
pub struct UpdatePostRequestDto {
pub title: String,
pub description: String,
pub preview_image_url: String,
pub content: String,
pub label_ids: Vec<i32>,
#[schema(required)]
pub published_time: Option<i64>,
}
impl UpdatePostRequestDto {
pub fn into_entity(self, id: i32) -> Post {
Post {
id,
info: PostInfo {
id,
title: self.title,
description: self.description,
preview_image_url: self.preview_image_url,
labels: Vec::new(),
published_time: self
.published_time
.map(|micros| DateTime::<Utc>::from_timestamp_micros(micros))
.flatten(),
},
content: self.content,
}
}
}

View File

@ -1,7 +1,7 @@
use async_trait::async_trait;
use crate::{
adapter::gateway::{post_info_db_mapper::PostInfoMapper, post_db_mapper::PostMapper},
adapter::gateway::{post_db_mapper::PostMapper, post_info_db_mapper::PostInfoMapper},
application::error::post_error::PostError,
};
@ -11,5 +11,7 @@ pub trait PostDbService: Send + Sync {
&self,
is_published_only: bool,
) -> Result<Vec<PostInfoMapper>, PostError>;
async fn get_full_post(&self, id: i32) -> Result<PostMapper, PostError>;
async fn get_post_by_id(&self, id: i32) -> Result<PostMapper, PostError>;
async fn create_post(&self, post: PostMapper, label_ids: &[i32]) -> Result<i32, PostError>;
async fn update_post(&self, post: PostMapper, label_ids: &[i32]) -> Result<(), PostError>;
}

View File

@ -3,6 +3,7 @@ use std::sync::Arc;
use async_trait::async_trait;
use crate::{
adapter::gateway::{post_db_mapper::PostMapper, post_info_db_mapper::PostInfoMapper},
application::{error::post_error::PostError, gateway::post_repository::PostRepository},
domain::entity::{post::Post, post_info::PostInfo},
};
@ -33,10 +34,52 @@ impl PostRepository for PostRepositoryImpl {
})
}
async fn get_full_post(&self, id: i32) -> Result<Post, PostError> {
async fn get_post_by_id(&self, id: i32) -> Result<Post, PostError> {
self.post_db_service
.get_full_post(id)
.get_post_by_id(id)
.await
.map(|mapper| mapper.into_entity())
}
async fn create_post(&self, post: Post, label_ids: &[i32]) -> Result<i32, PostError> {
let info_mapper = PostInfoMapper {
id: post.info.id,
title: post.info.title,
description: post.info.description,
preview_image_url: post.info.preview_image_url,
labels: Vec::new(),
published_time: post.info.published_time.map(|dt| dt.naive_utc()),
};
let post_mapper = PostMapper {
id: post.id,
info: info_mapper,
content: post.content,
};
self.post_db_service
.create_post(post_mapper, label_ids)
.await
}
async fn update_post(&self, post: Post, label_ids: &[i32]) -> Result<(), PostError> {
let info_mapper = PostInfoMapper {
id: post.info.id,
title: post.info.title,
description: post.info.description,
preview_image_url: post.info.preview_image_url,
labels: Vec::new(),
published_time: post.info.published_time.map(|dt| dt.naive_utc()),
};
let post_mapper = PostMapper {
id: post.id,
info: info_mapper,
content: post.content,
};
self.post_db_service
.update_post(post_mapper, label_ids)
.await
}
}

View File

@ -8,5 +8,7 @@ use crate::{
#[async_trait]
pub trait PostRepository: Send + Sync {
async fn get_all_post_info(&self, is_published_only: bool) -> Result<Vec<PostInfo>, PostError>;
async fn get_full_post(&self, id: i32) -> Result<Post, PostError>;
async fn get_post_by_id(&self, id: i32) -> Result<Post, PostError>;
async fn create_post(&self, post: Post, label_ids: &[i32]) -> Result<i32, PostError>;
async fn update_post(&self, post: Post, label_ids: &[i32]) -> Result<(), PostError>;
}

View File

@ -1,5 +1,7 @@
pub mod create_label_use_case;
pub mod create_post_use_case;
pub mod get_all_labels_use_case;
pub mod get_all_post_info_use_case;
pub mod get_full_post_use_case;
pub mod update_label_use_case;
pub mod update_post_use_case;

View File

@ -0,0 +1,32 @@
use std::sync::Arc;
use async_trait::async_trait;
use crate::{
application::{error::post_error::PostError, gateway::post_repository::PostRepository},
domain::entity::post::Post,
};
#[async_trait]
pub trait CreatePostUseCase: Send + Sync {
async fn execute(&self, post: Post, label_ids: &[i32]) -> Result<i32, PostError>;
}
pub struct CreatePostUseCaseImpl {
post_repository: Arc<dyn PostRepository>,
}
impl CreatePostUseCaseImpl {
pub fn new(post_repository: Arc<dyn PostRepository>) -> Self {
Self { post_repository }
}
}
#[async_trait]
impl CreatePostUseCase for CreatePostUseCaseImpl {
async fn execute(&self, post: Post, label_ids: &[i32]) -> Result<i32, PostError> {
self.post_repository
.create_post(post, label_ids)
.await
}
}

View File

@ -25,6 +25,6 @@ impl GetFullPostUseCaseImpl {
#[async_trait]
impl GetFullPostUseCase for GetFullPostUseCaseImpl {
async fn execute(&self, id: i32) -> Result<Post, PostError> {
self.post_repository.get_full_post(id).await
self.post_repository.get_post_by_id(id).await
}
}

View File

@ -0,0 +1,30 @@
use std::sync::Arc;
use async_trait::async_trait;
use crate::{
application::{error::post_error::PostError, gateway::post_repository::PostRepository},
domain::entity::post::Post,
};
#[async_trait]
pub trait UpdatePostUseCase: Send + Sync {
async fn execute(&self, post: Post, label_ids: &[i32]) -> Result<(), PostError>;
}
pub struct UpdatePostUseCaseImpl {
post_repository: Arc<dyn PostRepository>,
}
impl UpdatePostUseCaseImpl {
pub fn new(post_repository: Arc<dyn PostRepository>) -> Self {
Self { post_repository }
}
}
#[async_trait]
impl UpdatePostUseCase for UpdatePostUseCaseImpl {
async fn execute(&self, post: Post, label_ids: &[i32]) -> Result<(), PostError> {
self.post_repository.update_post(post, label_ids).await
}
}

View File

@ -5,8 +5,8 @@ use sqlx::{Pool, Postgres};
use crate::{
adapter::gateway::{
color_db_mapper::ColorMapper, label_db_mapper::LabelMapper, post_db_service::PostDbService,
post_info_db_mapper::PostInfoMapper, post_db_mapper::PostMapper,
color_db_mapper::ColorMapper, label_db_mapper::LabelMapper, post_db_mapper::PostMapper,
post_db_service::PostDbService, post_info_db_mapper::PostInfoMapper,
},
application::error::post_error::PostError,
};
@ -105,7 +105,7 @@ impl PostDbService for PostDbServiceImpl {
Ok(ordered_posts)
}
async fn get_full_post(&self, id: i32) -> Result<PostMapper, PostError> {
async fn get_post_by_id(&self, id: i32) -> Result<PostMapper, PostError> {
let mut query_builder = sqlx::QueryBuilder::new(
r#"
SELECT
@ -182,4 +182,119 @@ impl PostDbService for PostDbServiceImpl {
None => Err(PostError::NotFound),
}
}
async fn create_post(&self, post: PostMapper, label_ids: &[i32]) -> Result<i32, PostError> {
let mut tx = self
.db_pool
.begin()
.await
.map_err(|err| PostError::DatabaseError(err.to_string()))?;
let post_id = sqlx::query_scalar!(
r#"
INSERT INTO post (
title, description, preview_image_url, content, published_time
) VALUES ($1, $2, $3, $4, $5)
RETURNING id
"#,
post.info.title,
post.info.description,
post.info.preview_image_url,
post.content,
post.info.published_time,
)
.fetch_one(&mut *tx)
.await
.map_err(|err| PostError::DatabaseError(err.to_string()))?;
for label_id in label_ids {
sqlx::query!(
r#"
INSERT INTO post_label (
post_id, label_id
) VALUES ($1, $2)
ON CONFLICT DO NOTHING
"#,
post_id,
label_id,
)
.execute(&mut *tx)
.await
.map_err(|err| PostError::DatabaseError(err.to_string()))?;
}
tx.commit()
.await
.map_err(|err| PostError::DatabaseError(err.to_string()))?;
Ok(post_id)
}
async fn update_post(&self, post: PostMapper, label_ids: &[i32]) -> Result<(), PostError> {
let mut tx = self
.db_pool
.begin()
.await
.map_err(|err| PostError::DatabaseError(err.to_string()))?;
let affected_rows = sqlx::query!(
r#"
UPDATE post
SET
title = $1,
description = $2,
preview_image_url = $3,
content = $4,
published_time = $5
WHERE id = $6
"#,
post.info.title,
post.info.description,
post.info.preview_image_url,
post.content,
post.info.published_time,
post.id,
)
.execute(&mut *tx)
.await
.map_err(|err| PostError::DatabaseError(err.to_string()))?
.rows_affected();
if affected_rows == 0 {
return Err(PostError::NotFound);
}
sqlx::query!(
r#"
DELETE FROM post_label
WHERE post_id = $1
"#,
post.id,
)
.execute(&mut *tx)
.await
.map_err(|err| PostError::DatabaseError(err.to_string()))?;
for label_id in label_ids {
sqlx::query!(
r#"
INSERT INTO post_label (
post_id, label_id
) VALUES ($1, $2)
ON CONFLICT DO NOTHING
"#,
post.id,
label_id,
)
.execute(&mut *tx)
.await
.map_err(|err| PostError::DatabaseError(err.to_string()))?;
}
tx.commit()
.await
.map_err(|err| PostError::DatabaseError(err.to_string()))?;
Ok(())
}
}

View File

@ -2,7 +2,9 @@ pub mod post_api_doc;
pub mod post_web_routes;
mod create_label_handler;
mod create_post_handler;
mod get_all_labels_handler;
mod get_all_post_info_handler;
mod get_post_by_id_handler;
mod update_label_handler;
mod update_post_handler;

View File

@ -0,0 +1,35 @@
use actix_web::{web, HttpResponse, Responder};
use auth::framework::web::auth_middleware::UserId;
use crate::adapter::delivery::{
create_post_request_dto::CreatePostRequestDto, post_controller::PostController,
post_response_dto::PostResponseDto,
};
#[utoipa::path(
post,
path = "/post",
tag = "post",
summary = "Create a new post",
responses(
(status = 201, body = PostResponseDto),
),
security(
("oauth2" = [])
)
)]
pub async fn create_post_handler(
post_controller: web::Data<dyn PostController>,
post_dto: web::Json<CreatePostRequestDto>,
_: UserId,
) -> impl Responder {
let result = post_controller.create_post(post_dto.into_inner()).await;
match result {
Ok(post) => HttpResponse::Created().json(post),
Err(e) => {
log::error!("{e:?}");
HttpResponse::InternalServerError().finish()
}
}
}

View File

@ -1,6 +1,6 @@
use crate::framework::web::{
create_label_handler, get_all_labels_handler, get_all_post_info_handler,
get_post_by_id_handler, update_label_handler,
create_label_handler, create_post_handler, get_all_labels_handler, get_all_post_info_handler,
get_post_by_id_handler, update_label_handler, update_post_handler,
};
use utoipa::{OpenApi, openapi};
@ -8,6 +8,8 @@ use utoipa::{OpenApi, openapi};
#[openapi(paths(
get_all_post_info_handler::get_all_post_info_handler,
get_post_by_id_handler::get_post_by_id_handler,
create_post_handler::create_post_handler,
update_post_handler::update_post_handler,
create_label_handler::create_label_handler,
update_label_handler::update_label_handler,
get_all_labels_handler::get_all_labels_handler

View File

@ -1,16 +1,20 @@
use actix_web::web;
use crate::framework::web::{
create_label_handler::create_label_handler, get_all_labels_handler::get_all_labels_handler,
create_label_handler::create_label_handler, create_post_handler::create_post_handler,
get_all_labels_handler::get_all_labels_handler,
get_all_post_info_handler::get_all_post_info_handler,
get_post_by_id_handler::get_post_by_id_handler, update_label_handler::update_label_handler,
update_post_handler::update_post_handler,
};
pub fn configure_post_routes(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/post")
.route("", web::get().to(get_all_post_info_handler))
.route("/{id}", web::get().to(get_post_by_id_handler)),
.route("", web::post().to(create_post_handler))
.route("/{id}", web::get().to(get_post_by_id_handler))
.route("/{id}", web::put().to(update_post_handler)),
);
cfg.service(

View File

@ -0,0 +1,42 @@
use actix_web::{HttpResponse, Responder, web};
use auth::framework::web::auth_middleware::UserId;
use crate::adapter::delivery::{
post_controller::PostController, post_response_dto::PostResponseDto,
update_post_request_dto::UpdatePostRequestDto,
};
#[utoipa::path(
put,
path = "/post/{id}",
tag = "post",
summary = "Update a post by ID",
responses(
(status = 200, body = PostResponseDto),
),
security(
("oauth2" = [])
)
)]
pub async fn update_post_handler(
post_controller: web::Data<dyn PostController>,
path: web::Path<i32>,
post_dto: web::Json<UpdatePostRequestDto>,
_: UserId,
) -> impl Responder {
let id = path.into_inner();
let result = post_controller.update_post(id, post_dto.into_inner()).await;
match result {
Ok(post) => HttpResponse::Ok().json(post),
Err(e) => {
log::error!("{e:?}");
match e {
crate::application::error::post_error::PostError::NotFound => {
HttpResponse::NotFound().finish()
}
_ => HttpResponse::InternalServerError().finish(),
}
}
}
}

View File

@ -37,10 +37,12 @@ use post::{
},
application::use_case::{
create_label_use_case::CreateLabelUseCaseImpl,
create_post_use_case::CreatePostUseCaseImpl,
get_all_labels_use_case::GetAllLabelsUseCaseImpl,
get_all_post_info_use_case::GetAllPostInfoUseCaseImpl,
get_full_post_use_case::GetFullPostUseCaseImpl,
update_label_use_case::UpdateLabelUseCaseImpl,
update_post_use_case::UpdatePostUseCaseImpl,
},
framework::db::{
label_db_service_impl::LabelDbServiceImpl, post_db_service_impl::PostDbServiceImpl,
@ -96,6 +98,8 @@ impl Container {
let get_all_post_info_use_case =
Arc::new(GetAllPostInfoUseCaseImpl::new(post_repository.clone()));
let get_full_post_use_case = Arc::new(GetFullPostUseCaseImpl::new(post_repository.clone()));
let create_post_use_case = Arc::new(CreatePostUseCaseImpl::new(post_repository.clone()));
let update_post_use_case = Arc::new(UpdatePostUseCaseImpl::new(post_repository.clone()));
let create_label_use_case = Arc::new(CreateLabelUseCaseImpl::new(label_repository.clone()));
let update_label_use_case = Arc::new(UpdateLabelUseCaseImpl::new(label_repository.clone()));
let get_all_labels_use_case =
@ -104,6 +108,8 @@ impl Container {
let post_controller = Arc::new(PostControllerImpl::new(
get_all_post_info_use_case,
get_full_post_use_case,
create_post_use_case,
update_post_use_case,
create_label_use_case,
update_label_use_case,
get_all_labels_use_case,