diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..f2a45cb --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,21 @@ +HOST=127.0.0.1 +PORT=8080 + +DATABASE_HOST=127.0.0.1 +DATABASE_PORT=5432 +DATABASE_USER=postgres +DATABASE_PASSWORD= +DATABASE_NAME=postgres + +# For sqlx migrations +DATABASE_URL=postgres://postgres@127.0.0.1:5432/postgres + +OIDC_ISSUER_URL=https://xxx +OIDC_CLIENT_ID=xxx +OIDC_CLIENT_SECRET=xxx +OIDC_REDIRECT_URL=http://localhost:8080/auth/callback + +SESSION_KEY='openssl rand -hex 64' +REDIS_URL=redis://127.0.0.1:6379 + +STORAGE_PATH=static diff --git a/backend/Cargo.lock b/backend/Cargo.lock index fc70e43..b9189d6 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -2407,6 +2407,7 @@ dependencies = [ "auth", "chrono", "common", + "regex", "sentry", "serde", "sqlx", @@ -2638,9 +2639,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "4a52d8d02cacdb176ef4678de6c052efb4b3da14b78e4db683a4252762be5433" dependencies = [ "aho-corasick", "memchr", @@ -2650,9 +2651,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "722166aa0d7438abbaa4d5cc2c649dac844e8c56d82fb3d33e9c34b5cd268fc6" dependencies = [ "aho-corasick", "memchr", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 3b56e47..d3092f2 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -5,7 +5,6 @@ members = [ "feature/common", "feature/image", "feature/post", - "feature/common", ] resolver = "2" @@ -33,6 +32,7 @@ openidconnect = { version = "4.0.1", features = [ "reqwest-blocking", ] } percent-encoding = "2.3.1" +regex = "1.12.1" sentry = { version = "0.42.0", features = ["actix", "anyhow"] } serde = { version = "1.0.219", features = ["derive"] } sqlx = { version = "0.8.5", features = [ diff --git a/backend/feature/post/Cargo.toml b/backend/feature/post/Cargo.toml index afd48c7..aecb5fd 100644 --- a/backend/feature/post/Cargo.toml +++ b/backend/feature/post/Cargo.toml @@ -8,6 +8,7 @@ actix-web.workspace = true anyhow.workspace = true async-trait.workspace = true chrono.workspace = true +regex.workspace = true sentry.workspace = true serde.workspace = true sqlx.workspace = true diff --git a/backend/feature/post/src/adapter/delivery/create_post_request_dto.rs b/backend/feature/post/src/adapter/delivery/create_post_request_dto.rs index c242d0a..874c059 100644 --- a/backend/feature/post/src/adapter/delivery/create_post_request_dto.rs +++ b/backend/feature/post/src/adapter/delivery/create_post_request_dto.rs @@ -6,6 +6,7 @@ use crate::domain::entity::{post::Post, post_info::PostInfo}; #[derive(Deserialize, ToSchema, Clone)] pub struct CreatePostRequestDto { + pub semantic_id: String, pub title: String, pub description: String, pub content: String, @@ -24,6 +25,7 @@ impl CreatePostRequestDto { id: -1, info: PostInfo { id: -1, + semantic_id: self.semantic_id, title: self.title, description: self.description, preview_image_url: self.preview_image_url, diff --git a/backend/feature/post/src/adapter/delivery/post_controller.rs b/backend/feature/post/src/adapter/delivery/post_controller.rs index ca2ef5b..56c9034 100644 --- a/backend/feature/post/src/adapter/delivery/post_controller.rs +++ b/backend/feature/post/src/adapter/delivery/post_controller.rs @@ -15,8 +15,9 @@ use crate::{ 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, + get_post_by_id_use_case::GetPostByIdUseCase, + get_post_by_semantic_id_use_case::GetPostBySemanticIdUseCase, + update_label_use_case::UpdateLabelUseCase, update_post_use_case::UpdatePostUseCase, }, }, }; @@ -34,9 +35,9 @@ pub trait PostController: Send + Sync { user_id: Option, ) -> Result, PostError>; - async fn get_post_by_id( + async fn get_post_by_id_or_semantic_id( &self, - id: i32, + id_or_semantic_id: &str, user_id: Option, ) -> Result; @@ -69,7 +70,8 @@ pub trait PostController: Send + Sync { pub struct PostControllerImpl { get_all_post_info_use_case: Arc, - get_full_post_use_case: Arc, + get_post_by_id_use_case: Arc, + get_post_by_semantic_id_use_case: Arc, create_post_use_case: Arc, update_post_use_case: Arc, create_label_use_case: Arc, @@ -80,7 +82,8 @@ pub struct PostControllerImpl { impl PostControllerImpl { pub fn new( get_all_post_info_use_case: Arc, - get_full_post_use_case: Arc, + get_post_by_id_use_case: Arc, + get_post_by_semantic_id_use_case: Arc, create_post_use_case: Arc, update_post_use_case: Arc, create_label_use_case: Arc, @@ -89,7 +92,8 @@ impl PostControllerImpl { ) -> Self { Self { get_all_post_info_use_case, - get_full_post_use_case, + get_post_by_id_use_case, + get_post_by_semantic_id_use_case, create_post_use_case, update_post_use_case, create_label_use_case, @@ -97,6 +101,29 @@ impl PostControllerImpl { get_all_labels_use_case, } } + + async fn get_post_by_id( + &self, + id: i32, + user_id: Option, + ) -> Result { + let result = self.get_post_by_id_use_case.execute(id, user_id).await; + + result.map(PostResponseDto::from) + } + + async fn get_post_by_semantic_id( + &self, + semantic_id: &str, + user_id: Option, + ) -> Result { + let result = self + .get_post_by_semantic_id_use_case + .execute(semantic_id, user_id) + .await; + + result.map(PostResponseDto::from) + } } #[async_trait] @@ -121,14 +148,17 @@ impl PostController for PostControllerImpl { }) } - async fn get_post_by_id( + async fn get_post_by_id_or_semantic_id( &self, - id: i32, + id_or_semantic_id: &str, user_id: Option, ) -> Result { - let result = self.get_full_post_use_case.execute(id, user_id).await; - - result.map(PostResponseDto::from) + if let Ok(id) = id_or_semantic_id.parse::() { + self.get_post_by_id(id, user_id).await + } else { + let semantic_id = id_or_semantic_id; + self.get_post_by_semantic_id(semantic_id, user_id).await + } } async fn create_label( diff --git a/backend/feature/post/src/adapter/delivery/post_info_response_dto.rs b/backend/feature/post/src/adapter/delivery/post_info_response_dto.rs index 1a5e94e..bb83498 100644 --- a/backend/feature/post/src/adapter/delivery/post_info_response_dto.rs +++ b/backend/feature/post/src/adapter/delivery/post_info_response_dto.rs @@ -8,6 +8,7 @@ use super::label_response_dto::LabelResponseDto; #[derive(Serialize, ToSchema)] pub struct PostInfoResponseDto { pub id: i32, + pub semantic_id: String, pub title: String, pub description: String, pub labels: Vec, @@ -23,6 +24,7 @@ impl From for PostInfoResponseDto { fn from(entity: PostInfo) -> Self { Self { id: entity.id, + semantic_id: entity.semantic_id, title: entity.title, description: entity.description, preview_image_url: entity.preview_image_url, diff --git a/backend/feature/post/src/adapter/delivery/update_post_request_dto.rs b/backend/feature/post/src/adapter/delivery/update_post_request_dto.rs index 29f96a7..5f93c5f 100644 --- a/backend/feature/post/src/adapter/delivery/update_post_request_dto.rs +++ b/backend/feature/post/src/adapter/delivery/update_post_request_dto.rs @@ -6,6 +6,7 @@ use crate::domain::entity::{post::Post, post_info::PostInfo}; #[derive(Deserialize, ToSchema, Clone)] pub struct UpdatePostRequestDto { + pub semantic_id: String, pub title: String, pub description: String, pub content: String, @@ -24,6 +25,7 @@ impl UpdatePostRequestDto { id, info: PostInfo { id, + semantic_id: self.semantic_id, title: self.title, description: self.description, preview_image_url: self.preview_image_url, diff --git a/backend/feature/post/src/adapter/gateway/post_db_service.rs b/backend/feature/post/src/adapter/gateway/post_db_service.rs index ff330b8..7d6e1e9 100644 --- a/backend/feature/post/src/adapter/gateway/post_db_service.rs +++ b/backend/feature/post/src/adapter/gateway/post_db_service.rs @@ -14,4 +14,5 @@ pub trait PostDbService: Send + Sync { async fn get_post_by_id(&self, id: i32) -> Result; async fn create_post(&self, post: PostMapper, label_ids: &[i32]) -> Result; async fn update_post(&self, post: PostMapper, label_ids: &[i32]) -> Result<(), PostError>; + async fn get_id_by_semantic_id(&self, semantic_id: &str) -> Result; } diff --git a/backend/feature/post/src/adapter/gateway/post_info_db_mapper.rs b/backend/feature/post/src/adapter/gateway/post_info_db_mapper.rs index 9b3ee32..6663175 100644 --- a/backend/feature/post/src/adapter/gateway/post_info_db_mapper.rs +++ b/backend/feature/post/src/adapter/gateway/post_info_db_mapper.rs @@ -4,6 +4,7 @@ use crate::{adapter::gateway::label_db_mapper::LabelMapper, domain::entity::post pub struct PostInfoMapper { pub id: i32, + pub semantic_id: String, pub title: String, pub description: String, pub preview_image_url: String, @@ -15,13 +16,18 @@ impl PostInfoMapper { pub fn into_entity(self) -> PostInfo { PostInfo { id: self.id, + semantic_id: self.semantic_id, title: self.title.clone(), description: self.description.clone(), preview_image_url: self.preview_image_url.clone(), published_time: self .published_time .map(|dt| DateTime::::from_naive_utc_and_offset(dt, Utc)), - labels: self.labels.into_iter().map(LabelMapper::into_entity).collect(), + labels: self + .labels + .into_iter() + .map(LabelMapper::into_entity) + .collect(), } } } diff --git a/backend/feature/post/src/adapter/gateway/post_repository_impl.rs b/backend/feature/post/src/adapter/gateway/post_repository_impl.rs index 6fb0f97..a59e2ce 100644 --- a/backend/feature/post/src/adapter/gateway/post_repository_impl.rs +++ b/backend/feature/post/src/adapter/gateway/post_repository_impl.rs @@ -44,6 +44,7 @@ impl PostRepository for PostRepositoryImpl { async fn create_post(&self, post: Post, label_ids: &[i32]) -> Result { let info_mapper = PostInfoMapper { id: post.info.id, + semantic_id: post.info.semantic_id, title: post.info.title, description: post.info.description, preview_image_url: post.info.preview_image_url, @@ -65,6 +66,7 @@ impl PostRepository for PostRepositoryImpl { async fn update_post(&self, post: Post, label_ids: &[i32]) -> Result<(), PostError> { let info_mapper = PostInfoMapper { id: post.info.id, + semantic_id: post.info.semantic_id, title: post.info.title, description: post.info.description, preview_image_url: post.info.preview_image_url, @@ -82,4 +84,8 @@ impl PostRepository for PostRepositoryImpl { .update_post(post_mapper, label_ids) .await } + + async fn get_id_by_semantic_id(&self, semantic_id: &str) -> Result { + self.post_db_service.get_id_by_semantic_id(semantic_id).await + } } diff --git a/backend/feature/post/src/application/error/post_error.rs b/backend/feature/post/src/application/error/post_error.rs index 58ce49a..4d13e4e 100644 --- a/backend/feature/post/src/application/error/post_error.rs +++ b/backend/feature/post/src/application/error/post_error.rs @@ -4,6 +4,7 @@ use std::fmt::Display; pub enum PostError { NotFound, Unauthorized, + InvalidSemanticId, Unexpected(anyhow::Error), } @@ -12,6 +13,10 @@ impl Display for PostError { match self { PostError::NotFound => write!(f, "Post not found"), PostError::Unauthorized => write!(f, "Unauthorized access to post"), + PostError::InvalidSemanticId => write!( + f, + "Semantic ID shouldn't be numeric and must conform to `^[0-9a-zA-Z_\\-]+$`" + ), PostError::Unexpected(e) => write!(f, "Unexpected error: {}", e), } } diff --git a/backend/feature/post/src/application/gateway/post_repository.rs b/backend/feature/post/src/application/gateway/post_repository.rs index 36f910e..df568f0 100644 --- a/backend/feature/post/src/application/gateway/post_repository.rs +++ b/backend/feature/post/src/application/gateway/post_repository.rs @@ -11,4 +11,5 @@ pub trait PostRepository: Send + Sync { async fn get_post_by_id(&self, id: i32) -> Result; async fn create_post(&self, post: Post, label_ids: &[i32]) -> Result; async fn update_post(&self, post: Post, label_ids: &[i32]) -> Result<(), PostError>; + async fn get_id_by_semantic_id(&self, semantic_id: &str) -> Result; } diff --git a/backend/feature/post/src/application/use_case.rs b/backend/feature/post/src/application/use_case.rs index 88d3d50..3afc291 100644 --- a/backend/feature/post/src/application/use_case.rs +++ b/backend/feature/post/src/application/use_case.rs @@ -2,6 +2,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 get_post_by_id_use_case; +pub mod get_post_by_semantic_id_use_case; pub mod update_label_use_case; pub mod update_post_use_case; diff --git a/backend/feature/post/src/application/use_case/create_post_use_case.rs b/backend/feature/post/src/application/use_case/create_post_use_case.rs index 892f9a7..2e87793 100644 --- a/backend/feature/post/src/application/use_case/create_post_use_case.rs +++ b/backend/feature/post/src/application/use_case/create_post_use_case.rs @@ -25,8 +25,7 @@ impl CreatePostUseCaseImpl { #[async_trait] impl CreatePostUseCase for CreatePostUseCaseImpl { async fn execute(&self, post: Post, label_ids: &[i32]) -> Result { - self.post_repository - .create_post(post, label_ids) - .await + post.validate()?; + self.post_repository.create_post(post, label_ids).await } } diff --git a/backend/feature/post/src/application/use_case/get_full_post_use_case.rs b/backend/feature/post/src/application/use_case/get_post_by_id_use_case.rs similarity index 89% rename from backend/feature/post/src/application/use_case/get_full_post_use_case.rs rename to backend/feature/post/src/application/use_case/get_post_by_id_use_case.rs index 1a161a6..331cb9d 100644 --- a/backend/feature/post/src/application/use_case/get_full_post_use_case.rs +++ b/backend/feature/post/src/application/use_case/get_post_by_id_use_case.rs @@ -8,7 +8,7 @@ use crate::{ }; #[async_trait] -pub trait GetFullPostUseCase: Send + Sync { +pub trait GetPostByIdUseCase: Send + Sync { async fn execute(&self, id: i32, user_id: Option) -> Result; } @@ -23,7 +23,7 @@ impl GetFullPostUseCaseImpl { } #[async_trait] -impl GetFullPostUseCase for GetFullPostUseCaseImpl { +impl GetPostByIdUseCase for GetFullPostUseCaseImpl { async fn execute(&self, id: i32, user_id: Option) -> Result { let post = self.post_repository.get_post_by_id(id).await?; diff --git a/backend/feature/post/src/application/use_case/get_post_by_semantic_id_use_case.rs b/backend/feature/post/src/application/use_case/get_post_by_semantic_id_use_case.rs new file mode 100644 index 0000000..f6c62c3 --- /dev/null +++ b/backend/feature/post/src/application/use_case/get_post_by_semantic_id_use_case.rs @@ -0,0 +1,45 @@ +use std::sync::Arc; + +use async_trait::async_trait; + +use crate::{ + application::{ + error::post_error::PostError, gateway::post_repository::PostRepository, + use_case::get_post_by_id_use_case::GetPostByIdUseCase, + }, + domain::entity::post::Post, +}; + +#[async_trait] +pub trait GetPostBySemanticIdUseCase: Send + Sync { + async fn execute(&self, semantic_id: &str, user_id: Option) -> Result; +} + +pub struct GetPostBySemanticIdUseCaseImpl { + post_repository: Arc, + get_post_by_id_use_case: Arc, +} + +impl GetPostBySemanticIdUseCaseImpl { + pub fn new( + post_repository: Arc, + get_post_by_id_use_case: Arc, + ) -> Self { + Self { + post_repository, + get_post_by_id_use_case, + } + } +} + +#[async_trait] +impl GetPostBySemanticIdUseCase for GetPostBySemanticIdUseCaseImpl { + async fn execute(&self, semantic_id: &str, user_id: Option) -> Result { + let id = self + .post_repository + .get_id_by_semantic_id(semantic_id) + .await?; + + self.get_post_by_id_use_case.execute(id, user_id).await + } +} diff --git a/backend/feature/post/src/application/use_case/update_post_use_case.rs b/backend/feature/post/src/application/use_case/update_post_use_case.rs index 295cdba..ce5b6d9 100644 --- a/backend/feature/post/src/application/use_case/update_post_use_case.rs +++ b/backend/feature/post/src/application/use_case/update_post_use_case.rs @@ -25,6 +25,7 @@ impl UpdatePostUseCaseImpl { #[async_trait] impl UpdatePostUseCase for UpdatePostUseCaseImpl { async fn execute(&self, post: Post, label_ids: &[i32]) -> Result<(), PostError> { + post.validate()?; self.post_repository.update_post(post, label_ids).await } } diff --git a/backend/feature/post/src/domain/entity/post.rs b/backend/feature/post/src/domain/entity/post.rs index a968187..47e694a 100644 --- a/backend/feature/post/src/domain/entity/post.rs +++ b/backend/feature/post/src/domain/entity/post.rs @@ -1,3 +1,5 @@ +use crate::application::error::post_error::PostError; + use super::post_info::PostInfo; pub struct Post { @@ -5,3 +7,10 @@ pub struct Post { pub info: PostInfo, pub content: String, } + +impl Post { + pub fn validate(&self) -> Result<(), PostError> { + self.info.validate()?; + Ok(()) + } +} diff --git a/backend/feature/post/src/domain/entity/post_info.rs b/backend/feature/post/src/domain/entity/post_info.rs index ee5f7f9..922be11 100644 --- a/backend/feature/post/src/domain/entity/post_info.rs +++ b/backend/feature/post/src/domain/entity/post_info.rs @@ -1,12 +1,31 @@ use chrono::{DateTime, Utc}; +use regex::Regex; + +use crate::application::error::post_error::PostError; use super::label::Label; pub struct PostInfo { pub id: i32, + pub semantic_id: String, pub title: String, pub description: String, pub preview_image_url: String, pub labels: Vec