Compare commits

...

2 Commits

Author SHA1 Message Date
e49c5b888a BLOG-43 feat: get full post
All checks were successful
Frontend CI / build (push) Successful in 1m29s
PR Title Check / pr-title-check (pull_request) Successful in 16s
2025-05-22 15:49:09 +08:00
583ad8b236 BLOG-43 docs: readme document for backend start 2025-05-22 15:48:58 +08:00
20 changed files with 293 additions and 79 deletions

14
backend/Cargo.lock generated
View File

@ -1489,9 +1489,9 @@ dependencies = [
"actix-web", "actix-web",
"async-trait", "async-trait",
"chrono", "chrono",
"log",
"serde", "serde",
"sqlx", "sqlx",
"tokio",
] ]
[[package]] [[package]]
@ -2230,21 +2230,9 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
"signal-hook-registry", "signal-hook-registry",
"socket2", "socket2",
"tokio-macros",
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "tokio-macros"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "tokio-stream" name = "tokio-stream"
version = "0.1.17" version = "0.1.17"

View File

@ -13,6 +13,7 @@ chrono = "0.4.41"
dotenv = "0.15.0" dotenv = "0.15.0"
env_logger = "0.11.8" env_logger = "0.11.8"
futures = "0.3.31" futures = "0.3.31"
log = "0.4.27"
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
sqlx = { version = "0.8.5", features = [ sqlx = { version = "0.8.5", features = [
"chrono", "chrono",

42
backend/README.md Normal file
View File

@ -0,0 +1,42 @@
# Backend
## Development
### SQL Migration
1. Install sqlx
```bash
cargo install sqlx-cli
```
2. Run migration
```bash
sqlx migrate run
```
### Run Project
1. Prepare for sql schema setup
```bash
cargo sqlx prepare --workspace
```
2. Run the server
```bash
RUST_LOG=debug cargo run
```
3. (Optional) Hot restart
1. Install `watchexec`
2. Run the server with `watchexec`
```bash
RUST_LOG=debug watchexec -e rs -r 'cargo run'
```

View File

@ -7,6 +7,6 @@ edition.workspace = true
actix-web.workspace = true actix-web.workspace = true
async-trait.workspace = true async-trait.workspace = true
chrono.workspace = true chrono.workspace = true
log.workspace = true
serde.workspace = true serde.workspace = true
sqlx.workspace = true sqlx.workspace = true
tokio.workspace = true

View File

@ -1,3 +1,4 @@
pub mod label_response_dto; pub mod label_response_dto;
pub mod post_controller; pub mod post_controller;
pub mod post_info_response_dto; pub mod post_info_response_dto;
pub mod post_response_dto;

View File

@ -3,24 +3,34 @@ use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use crate::application::{ use crate::application::{
error::post_error::PostError, use_case::get_all_post_info_use_case::GetAllPostInfoUseCase, error::post_error::PostError,
use_case::{
get_all_post_info_use_case::GetAllPostInfoUseCase,
get_full_post_use_case::GetFullPostUseCase,
},
}; };
use super::post_info_response_dto::PostInfoResponseDto; use super::{post_info_response_dto::PostInfoResponseDto, post_response_dto::PostResponseDto};
#[async_trait] #[async_trait]
pub trait PostController: Send + Sync { pub trait PostController: Send + Sync {
async fn get_all_post_info(&self) -> Result<Vec<PostInfoResponseDto>, PostError>; async fn get_all_post_info(&self) -> Result<Vec<PostInfoResponseDto>, PostError>;
async fn get_full_post(&self, id: i32) -> Result<PostResponseDto, PostError>;
} }
pub struct PostControllerImpl { pub struct PostControllerImpl {
get_all_post_info_use_case: Arc<dyn GetAllPostInfoUseCase>, get_all_post_info_use_case: Arc<dyn GetAllPostInfoUseCase>,
get_full_post_use_case: Arc<dyn GetFullPostUseCase>,
} }
impl PostControllerImpl { impl PostControllerImpl {
pub fn new(get_all_post_info_use_case: Arc<dyn GetAllPostInfoUseCase>) -> Self { pub fn new(
get_all_post_info_use_case: Arc<dyn GetAllPostInfoUseCase>,
get_full_post_use_case: Arc<dyn GetFullPostUseCase>,
) -> Self {
Self { Self {
get_all_post_info_use_case, get_all_post_info_use_case,
get_full_post_use_case,
} }
} }
} }
@ -39,4 +49,10 @@ impl PostController for PostControllerImpl {
post_info_response_dto_list post_info_response_dto_list
}) })
} }
async fn get_full_post(&self, id: i32) -> Result<PostResponseDto, PostError> {
let result = self.get_full_post_use_case.execute(id).await;
result.map(PostResponseDto::from)
}
} }

View File

@ -0,0 +1,22 @@
use serde::Serialize;
use crate::domain::entity::post::Post;
use super::post_info_response_dto::PostInfoResponseDto;
#[derive(Serialize)]
pub struct PostResponseDto {
pub id: i32,
pub info: PostInfoResponseDto,
pub content: String,
}
impl From<Post> for PostResponseDto {
fn from(entity: Post) -> Self {
Self {
id: entity.id,
info: PostInfoResponseDto::from(entity.info),
content: entity.content,
}
}
}

View File

@ -1,8 +1,12 @@
use async_trait::async_trait; use async_trait::async_trait;
use crate::{application::error::post_error::PostError, domain::entity::post_info::PostInfo}; use crate::{
application::error::post_error::PostError,
domain::entity::{post::Post, post_info::PostInfo},
};
#[async_trait] #[async_trait]
pub trait PostDbService: Send + Sync { pub trait PostDbService: Send + Sync {
async fn get_all_post_info(&self) -> Result<Vec<PostInfo>, PostError>; async fn get_all_post_info(&self) -> Result<Vec<PostInfo>, PostError>;
async fn get_full_post(&self, id: i32) -> Result<Post, PostError>;
} }

View File

@ -4,7 +4,7 @@ use async_trait::async_trait;
use crate::{ use crate::{
application::{error::post_error::PostError, gateway::post_repository::PostRepository}, application::{error::post_error::PostError, gateway::post_repository::PostRepository},
domain::entity::post_info::PostInfo, domain::entity::{post::Post, post_info::PostInfo},
}; };
use super::post_db_service::PostDbService; use super::post_db_service::PostDbService;
@ -22,8 +22,10 @@ impl PostRepositoryImpl {
#[async_trait] #[async_trait]
impl PostRepository for PostRepositoryImpl { impl PostRepository for PostRepositoryImpl {
async fn get_all_post_info(&self) -> Result<Vec<PostInfo>, PostError> { async fn get_all_post_info(&self) -> Result<Vec<PostInfo>, PostError> {
self.post_db_service self.post_db_service.get_all_post_info().await
.get_all_post_info() }
.await
async fn get_full_post(&self, id: i32) -> Result<Post, PostError> {
self.post_db_service.get_full_post(id).await
} }
} }

View File

@ -1,3 +1,4 @@
#[derive(Debug)]
pub enum PostError { pub enum PostError {
DatabaseError(String), DatabaseError(String),
NotFound, NotFound,

View File

@ -1,8 +1,12 @@
use async_trait::async_trait; use async_trait::async_trait;
use crate::{application::error::post_error::PostError, domain::entity::post_info::PostInfo}; use crate::{
application::error::post_error::PostError,
domain::entity::{post::Post, post_info::PostInfo},
};
#[async_trait] #[async_trait]
pub trait PostRepository: Send + Sync { pub trait PostRepository: Send + Sync {
async fn get_all_post_info(&self) -> Result<Vec<PostInfo>, PostError>; async fn get_all_post_info(&self) -> Result<Vec<PostInfo>, PostError>;
async fn get_full_post(&self, id: i32) -> Result<Post, PostError>;
} }

View File

@ -1 +1,2 @@
pub mod get_all_post_info_use_case; pub mod get_all_post_info_use_case;
pub mod get_full_post_use_case;

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 GetFullPostUseCase: Send + Sync {
async fn execute(&self, id: i32) -> Result<Post, PostError>;
}
pub struct GetFullPostUseCaseImpl {
post_repository: Arc<dyn PostRepository>,
}
impl GetFullPostUseCaseImpl {
pub fn new(post_repository: Arc<dyn PostRepository>) -> Self {
Self { post_repository }
}
}
#[async_trait]
impl GetFullPostUseCase for GetFullPostUseCaseImpl {
async fn execute(&self, id: i32) -> Result<Post, PostError> {
self.post_repository.get_full_post(id).await
}
}

View File

@ -10,3 +10,4 @@ pub struct PostInfo {
pub labels: Vec<Label>, pub labels: Vec<Label>,
pub published_time: Option<DateTime<Utc>>, pub published_time: Option<DateTime<Utc>>,
} }

View File

@ -1,12 +1,13 @@
use std::sync::Arc; use std::{any::Any, sync::Arc};
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use sqlx::{Pool, Postgres}; use sqlx::{Error, Pool, Postgres};
use crate::{ use crate::{
adapter::gateway::post_db_service::PostDbService, application::error::post_error::PostError, adapter::gateway::post_db_service::PostDbService,
domain::entity::post_info::PostInfo, application::error::post_error::PostError,
domain::entity::{post::Post, post_info::PostInfo},
}; };
pub struct PostDbServiceImpl { pub struct PostDbServiceImpl {
@ -22,7 +23,7 @@ impl PostDbServiceImpl {
#[async_trait] #[async_trait]
impl PostDbService for PostDbServiceImpl { impl PostDbService for PostDbServiceImpl {
async fn get_all_post_info(&self) -> Result<Vec<PostInfo>, PostError> { async fn get_all_post_info(&self) -> Result<Vec<PostInfo>, PostError> {
let posts = sqlx::query!( let post_records = sqlx::query!(
r#" r#"
SELECT SELECT
p.id, p.id,
@ -38,10 +39,10 @@ impl PostDbService for PostDbServiceImpl {
.await .await
.map_err(|err| PostError::DatabaseError(err.to_string()))?; .map_err(|err| PostError::DatabaseError(err.to_string()))?;
let mut post_infos = Vec::new(); let mut post_infos = Vec::<PostInfo>::new();
for post in posts { for post_record in post_records {
let labels = sqlx::query!( let label_records = sqlx::query!(
r#" r#"
SELECT SELECT
l.id, l.id,
@ -52,13 +53,13 @@ impl PostDbService for PostDbServiceImpl {
WHERE pl.post_id = $1 WHERE pl.post_id = $1
AND l.deleted_time IS NULL AND l.deleted_time IS NULL
"#, "#,
post.id post_record.id
) )
.fetch_all(&*self.db_pool) .fetch_all(&*self.db_pool)
.await .await
.map_err(|err| PostError::DatabaseError(err.to_string()))?; .map_err(|err| PostError::DatabaseError(err.to_string()))?;
let domain_labels = labels let labels = label_records
.into_iter() .into_iter()
.map(|label| crate::domain::entity::label::Label { .map(|label| crate::domain::entity::label::Label {
id: label.id, id: label.id,
@ -68,12 +69,12 @@ impl PostDbService for PostDbServiceImpl {
.collect(); .collect();
post_infos.push(PostInfo { post_infos.push(PostInfo {
id: post.id, id: post_record.id,
title: post.title, title: post_record.title,
description: post.description, description: post_record.description,
preview_image_url: post.preview_image_url, preview_image_url: post_record.preview_image_url,
labels: domain_labels, labels: labels,
published_time: post published_time: post_record
.published_time .published_time
.map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc)), .map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc)),
}); });
@ -81,4 +82,75 @@ impl PostDbService for PostDbServiceImpl {
Ok(post_infos) Ok(post_infos)
} }
async fn get_full_post(&self, id: i32) -> Result<Post, PostError> {
let post_record = sqlx::query!(
r#"
SELECT
p.id,
p.title,
p.description,
p.preview_image_url,
p.content,
p.published_time
FROM post p
WHERE p.id = $1
AND p.deleted_time IS NULL
"#,
id
)
.fetch_one(&*self.db_pool)
.await
.map_err(|err| {
if err.type_id() == Error::RowNotFound.type_id() {
PostError::NotFound
} else {
PostError::DatabaseError(err.to_string())
}
})?;
// TODO: extract label selecting process
let label_records = sqlx::query!(
r#"
SELECT
l.id,
l.name,
l.color
FROM label l
JOIN post_label pl ON l.id = pl.label_id
WHERE pl.post_id = $1
AND l.deleted_time IS NULL
"#,
post_record.id
)
.fetch_all(&*self.db_pool)
.await
.map_err(|err| PostError::DatabaseError(err.to_string()))?;
let labels = label_records
.into_iter()
.map(|label| crate::domain::entity::label::Label {
id: label.id,
name: label.name,
color: label.color as u32,
})
.collect();
let post = Post {
id: post_record.id,
content: post_record.content,
info: PostInfo {
id: post_record.id,
title: post_record.title,
description: post_record.description,
preview_image_url: post_record.preview_image_url,
labels: labels,
published_time: post_record
.published_time
.map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc)),
},
};
Ok(post)
}
} }

View File

@ -1,24 +1,44 @@
use std::sync::Arc; use std::{any::Any, sync::Arc};
use actix_web::{HttpResponse, Responder, web}; use actix_web::{HttpResponse, Responder, web};
use crate::{ use crate::{
adapter::delivery::post_controller::{PostController, PostControllerImpl}, adapter::delivery::post_controller::PostController, application::error::post_error::PostError,
application::use_case::get_all_post_info_use_case::GetAllPostInfoUseCase,
}; };
pub fn configure_post_routes(cfg: &mut web::ServiceConfig) { pub fn configure_post_routes(cfg: &mut web::ServiceConfig) {
cfg.service(web::resource("/post_info").route(web::get().to(get_all_post_info))); cfg.service(web::resource("/post_info").route(web::get().to(get_all_post_info)));
cfg.service(web::resource("/post/{id}").route(web::get().to(get_full_post)));
} }
async fn get_all_post_info( async fn get_all_post_info(post_controller: web::Data<Arc<dyn PostController>>) -> impl Responder {
get_all_post_info_use_case: web::Data<Arc<dyn GetAllPostInfoUseCase>>,
) -> impl Responder {
let post_controller = PostControllerImpl::new(get_all_post_info_use_case.get_ref().clone());
let result = post_controller.get_all_post_info().await; let result = post_controller.get_all_post_info().await;
match result { match result {
Ok(post_info_list) => HttpResponse::Ok().json(post_info_list), Ok(post_info_list) => HttpResponse::Ok().json(post_info_list),
Err(_) => HttpResponse::InternalServerError().finish(), Err(e) => {
log::error!("{e:?}");
HttpResponse::InternalServerError().finish()
}
}
}
async fn get_full_post(
post_controller: web::Data<Arc<dyn PostController>>,
path: web::Path<i32>,
) -> impl Responder {
let id = path.into_inner();
let result = post_controller.get_full_post(id).await;
match result {
Ok(post) => HttpResponse::Ok().json(post),
Err(e) => {
if e.type_id() == PostError::NotFound.type_id() {
HttpResponse::NotFound().finish()
} else {
log::error!("{e:?}");
HttpResponse::InternalServerError().finish()
}
}
} }
} }

View File

@ -0,0 +1,37 @@
use std::sync::Arc;
use post::{
adapter::{
delivery::post_controller::{PostController, PostControllerImpl},
gateway::post_repository_impl::PostRepositoryImpl,
},
application::use_case::{
get_all_post_info_use_case::GetAllPostInfoUseCaseImpl,
get_full_post_use_case::GetFullPostUseCaseImpl,
},
framework::db::post_db_service_impl::PostDbServiceImpl,
};
use sqlx::{Pool, Postgres};
pub struct Container {
pub post_controller: Arc<dyn PostController>,
}
impl Container {
pub fn new(db_pool: Arc<Pool<Postgres>>) -> Self {
let post_db_service = Arc::new(PostDbServiceImpl::new(db_pool));
let post_repository = Arc::new(PostRepositoryImpl::new(post_db_service.clone()));
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 post_controller = Arc::new(PostControllerImpl::new(
get_all_post_info_use_case,
get_full_post_use_case,
));
Self { post_controller }
}
}

View File

@ -1 +1 @@
pub mod use_cases; pub mod container;

View File

@ -5,7 +5,7 @@ use actix_web::{
web, web,
}; };
use post::framework::web::post_web_routes::configure_post_routes; use post::framework::web::post_web_routes::configure_post_routes;
use server::use_cases::UseCases; use server::container::Container;
use sqlx::{Pool, Postgres, postgres::PgPoolOptions}; use sqlx::{Pool, Postgres, postgres::PgPoolOptions};
use std::{env, sync::Arc}; use std::{env, sync::Arc};
@ -51,10 +51,10 @@ fn create_app(
Error = Error, Error = Error,
>, >,
> { > {
let use_cases = UseCases::new(db_pool.clone()); let container = Container::new(db_pool.clone());
App::new() App::new()
.app_data(web::Data::new(db_pool)) .app_data(web::Data::new(db_pool))
.app_data(web::Data::new(use_cases.get_all_post_info_use_case)) .app_data(web::Data::new(container.post_controller))
.configure(configure_post_routes) .configure(configure_post_routes)
} }

View File

@ -1,28 +0,0 @@
use std::sync::Arc;
use post::{
adapter::gateway::post_repository_impl::PostRepositoryImpl,
application::use_case::get_all_post_info_use_case::{
GetAllPostInfoUseCase, GetAllPostInfoUseCaseImpl,
},
framework::db::post_db_service_impl::PostDbServiceImpl,
};
use sqlx::{Pool, Postgres};
pub struct UseCases {
pub get_all_post_info_use_case: Arc<dyn GetAllPostInfoUseCase>,
}
impl UseCases {
pub fn new(db_pool: Arc<Pool<Postgres>>) -> Self {
let post_db_service = Arc::new(PostDbServiceImpl::new(db_pool));
let post_repository = Arc::new(PostRepositoryImpl::new(post_db_service));
let get_all_post_info_use_case = Arc::new(GetAllPostInfoUseCaseImpl::new(post_repository));
Self {
get_all_post_info_use_case,
}
}
}