BLOG-142 Move label to a new feature (#143)
All checks were successful
Frontend CI / build (push) Successful in 1m35s

### Description

- As the title

### Package Changes

_No response_

### Screenshots

_No response_

### Reference

Resolves #142.

### Checklist

- [x] A milestone is set
- [x] The related issuse has been linked to this branch

Reviewed-on: #143
Co-authored-by: SquidSpirit <squid@squidspirit.com>
Co-committed-by: SquidSpirit <squid@squidspirit.com>
This commit is contained in:
SquidSpirit 2025-10-15 06:23:58 +08:00 committed by squid
parent 24a98f8f70
commit a577f94acd
81 changed files with 402 additions and 259 deletions

17
backend/Cargo.lock generated
View File

@ -1876,6 +1876,21 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "label"
version = "0.3.1"
dependencies = [
"actix-web",
"anyhow",
"async-trait",
"auth",
"common",
"sentry",
"serde",
"sqlx",
"utoipa",
]
[[package]]
name = "language-tags"
version = "0.3.2"
@ -2407,6 +2422,7 @@ dependencies = [
"auth",
"chrono",
"common",
"label",
"regex",
"sentry",
"serde",
@ -3179,6 +3195,7 @@ dependencies = [
"env_logger",
"hex",
"image",
"label",
"openidconnect",
"percent-encoding",
"post",

View File

@ -4,6 +4,7 @@ members = [
"feature/auth",
"feature/common",
"feature/image",
"feature/label",
"feature/post",
]
resolver = "2"
@ -53,4 +54,5 @@ server.path = "server"
auth.path = "feature/auth"
common.path = "feature/common"
image.path = "feature/image"
label.path = "feature/label"
post.path = "feature/post"

View File

@ -7,6 +7,12 @@ pub enum ImageError {
Unexpected(anyhow::Error),
}
impl Into<String> for ImageError {
fn into(self) -> String {
format!("{}", self)
}
}
impl Display for ImageError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {

View File

@ -15,7 +15,7 @@ use crate::{
summary = "Get image by ID",
responses (
(status = 200, body = inline(ResponseBodySchema), content_type = "image/*"),
(status = 404, description = "Image not found")
(status = 404, description = ImageError::NotFound),
)
)]
pub async fn get_image_by_id_handler(
@ -31,12 +31,12 @@ pub async fn get_image_by_id_handler(
.body(image_response.data),
Err(e) => match e {
ImageError::NotFound => HttpResponse::NotFound().finish(),
ImageError::Unexpected(e) => {
capture_anyhow(&e);
ImageError::UnsupportedMimeType(_) => {
capture_anyhow(&anyhow!(e));
HttpResponse::InternalServerError().finish()
}
_ => {
capture_anyhow(&anyhow!(e));
ImageError::Unexpected(e) => {
capture_anyhow(&e);
HttpResponse::InternalServerError().finish()
}
},

View File

@ -25,7 +25,7 @@ use crate::{
),
responses (
(status = 201, body = ImageInfoResponseDto),
(status = 400, description = "Unsupported MIME type or file field not found"),
(status = 400, description = ImageError::UnsupportedMimeType("{MIME_TYPE}".to_string())),
),
security(
("oauth2" = [])
@ -78,12 +78,12 @@ pub async fn upload_image_handler(
ImageError::UnsupportedMimeType(mime_type) => {
HttpResponse::BadRequest().body(format!("Unsupported MIME type: {}", mime_type))
}
ImageError::Unexpected(e) => {
capture_anyhow(&e);
ImageError::NotFound => {
capture_anyhow(&anyhow!(e));
HttpResponse::InternalServerError().finish()
}
_ => {
capture_anyhow(&anyhow!(e));
ImageError::Unexpected(e) => {
capture_anyhow(&e);
HttpResponse::InternalServerError().finish()
}
},

View File

@ -0,0 +1,16 @@
[package]
name = "label"
version.workspace = true
edition.workspace = true
[dependencies]
actix-web.workspace = true
anyhow.workspace = true
async-trait.workspace = true
sentry.workspace = true
serde.workspace = true
sqlx.workspace = true
utoipa.workspace = true
auth.workspace = true
common.workspace = true

View File

@ -0,0 +1,2 @@
pub mod delivery;
pub mod gateway;

View File

@ -0,0 +1,6 @@
pub mod color_request_dto;
pub mod color_response_dto;
pub mod create_label_request_dto;
pub mod label_controller;
pub mod label_response_dto;
pub mod update_label_request_dto;

View File

@ -0,0 +1,95 @@
use std::sync::Arc;
use async_trait::async_trait;
use crate::{
adapter::delivery::{
create_label_request_dto::CreateLabelRequestDto, label_response_dto::LabelResponseDto,
update_label_request_dto::UpdateLabelRequestDto,
},
application::{
error::label_error::LabelError,
use_case::{
create_label_use_case::CreateLabelUseCase,
get_all_labels_use_case::GetAllLabelsUseCase,
update_label_use_case::UpdateLabelUseCase,
},
},
};
#[async_trait]
pub trait LabelController: Send + Sync {
async fn create_label(
&self,
label: CreateLabelRequestDto,
) -> Result<LabelResponseDto, LabelError>;
async fn update_label(
&self,
id: i32,
label: UpdateLabelRequestDto,
) -> Result<LabelResponseDto, LabelError>;
async fn get_all_labels(&self) -> Result<Vec<LabelResponseDto>, LabelError>;
}
pub struct LabelControllerImpl {
create_label_use_case: Arc<dyn CreateLabelUseCase>,
update_label_use_case: Arc<dyn UpdateLabelUseCase>,
get_all_labels_use_case: Arc<dyn GetAllLabelsUseCase>,
}
impl LabelControllerImpl {
pub fn new(
create_label_use_case: Arc<dyn CreateLabelUseCase>,
update_label_use_case: Arc<dyn UpdateLabelUseCase>,
get_all_labels_use_case: Arc<dyn GetAllLabelsUseCase>,
) -> Self {
Self {
create_label_use_case,
update_label_use_case,
get_all_labels_use_case,
}
}
}
#[async_trait]
impl LabelController for LabelControllerImpl {
async fn create_label(
&self,
label: CreateLabelRequestDto,
) -> Result<LabelResponseDto, LabelError> {
let mut label_entity = label.into_entity();
let id = self
.create_label_use_case
.execute(label_entity.clone())
.await?;
label_entity.id = id;
Ok(LabelResponseDto::from(label_entity))
}
async fn update_label(
&self,
id: i32,
label: UpdateLabelRequestDto,
) -> Result<LabelResponseDto, LabelError> {
let label_entity = label.into_entity(id);
self.update_label_use_case
.execute(label_entity.clone())
.await?;
Ok(LabelResponseDto::from(label_entity))
}
async fn get_all_labels(&self) -> Result<Vec<LabelResponseDto>, LabelError> {
let result = self.get_all_labels_use_case.execute().await;
result.map(|labels| {
labels
.into_iter()
.map(|label| LabelResponseDto::from(label))
.collect()
})
}
}

View File

@ -0,0 +1,4 @@
pub mod color_db_mapper;
pub mod label_db_mapper;
pub mod label_db_service;
pub mod label_repository_impl;

View File

@ -1,13 +1,13 @@
use async_trait::async_trait;
use crate::{
adapter::gateway::label_db_mapper::LabelMapper, application::error::post_error::PostError,
adapter::gateway::label_db_mapper::LabelMapper, application::error::label_error::LabelError,
};
#[async_trait]
pub trait LabelDbService: Send + Sync {
async fn create_label(&self, label: LabelMapper) -> Result<i32, PostError>;
async fn update_label(&self, label: LabelMapper) -> Result<(), PostError>;
async fn get_label_by_id(&self, id: i32) -> Result<LabelMapper, PostError>;
async fn get_all_labels(&self) -> Result<Vec<LabelMapper>, PostError>;
async fn create_label(&self, label: LabelMapper) -> Result<i32, LabelError>;
async fn update_label(&self, label: LabelMapper) -> Result<(), LabelError>;
async fn get_label_by_id(&self, id: i32) -> Result<LabelMapper, LabelError>;
async fn get_all_labels(&self) -> Result<Vec<LabelMapper>, LabelError>;
}

View File

@ -4,7 +4,7 @@ use async_trait::async_trait;
use crate::{
adapter::gateway::{label_db_mapper::LabelMapper, label_db_service::LabelDbService},
application::{error::post_error::PostError, gateway::label_repository::LabelRepository},
application::{error::label_error::LabelError, gateway::label_repository::LabelRepository},
domain::entity::label::Label,
};
@ -20,26 +20,26 @@ impl LabelRepositoryImpl {
#[async_trait]
impl LabelRepository for LabelRepositoryImpl {
async fn create_label(&self, label: Label) -> Result<i32, PostError> {
async fn create_label(&self, label: Label) -> Result<i32, LabelError> {
self.label_db_service
.create_label(LabelMapper::from(label))
.await
}
async fn update_label(&self, label: Label) -> Result<(), PostError> {
async fn update_label(&self, label: Label) -> Result<(), LabelError> {
self.label_db_service
.update_label(LabelMapper::from(label))
.await
}
async fn get_label_by_id(&self, id: i32) -> Result<Label, PostError> {
async fn get_label_by_id(&self, id: i32) -> Result<Label, LabelError> {
self.label_db_service
.get_label_by_id(id)
.await
.map(|mapper| mapper.into_entity())
}
async fn get_all_labels(&self) -> Result<Vec<Label>, PostError> {
async fn get_all_labels(&self) -> Result<Vec<Label>, LabelError> {
self.label_db_service.get_all_labels().await.map(|mappers| {
mappers
.into_iter()

View File

@ -0,0 +1,3 @@
pub mod error;
pub mod gateway;
pub mod use_case;

View File

@ -0,0 +1 @@
pub mod label_error;

View File

@ -0,0 +1,26 @@
use std::fmt::Display;
#[derive(Debug)]
pub enum LabelError {
NotFound,
Unauthorized,
DuplicatedLabelName,
Unexpected(anyhow::Error),
}
impl Into<String> for LabelError {
fn into(self) -> String {
format!("{}", self)
}
}
impl Display for LabelError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LabelError::NotFound => write!(f, "Label not found"),
LabelError::Unauthorized => write!(f, "Unauthorized access"),
LabelError::DuplicatedLabelName => write!(f, "Label name already exists"),
LabelError::Unexpected(e) => write!(f, "Unexpected error: {}", e),
}
}
}

View File

@ -0,0 +1 @@
pub mod label_repository;

View File

@ -0,0 +1,11 @@
use async_trait::async_trait;
use crate::{application::error::label_error::LabelError, domain::entity::label::Label};
#[async_trait]
pub trait LabelRepository: Send + Sync {
async fn create_label(&self, label: Label) -> Result<i32, LabelError>;
async fn update_label(&self, label: Label) -> Result<(), LabelError>;
async fn get_label_by_id(&self, id: i32) -> Result<Label, LabelError>;
async fn get_all_labels(&self) -> Result<Vec<Label>, LabelError>;
}

View File

@ -0,0 +1,3 @@
pub mod create_label_use_case;
pub mod get_all_labels_use_case;
pub mod update_label_use_case;

View File

@ -3,13 +3,13 @@ use std::sync::Arc;
use async_trait::async_trait;
use crate::{
application::{error::post_error::PostError, gateway::label_repository::LabelRepository},
application::{error::label_error::LabelError, gateway::label_repository::LabelRepository},
domain::entity::label::Label,
};
#[async_trait]
pub trait CreateLabelUseCase: Send + Sync {
async fn execute(&self, label: Label) -> Result<i32, PostError>;
async fn execute(&self, label: Label) -> Result<i32, LabelError>;
}
pub struct CreateLabelUseCaseImpl {
@ -24,7 +24,7 @@ impl CreateLabelUseCaseImpl {
#[async_trait]
impl CreateLabelUseCase for CreateLabelUseCaseImpl {
async fn execute(&self, label: Label) -> Result<i32, PostError> {
async fn execute(&self, label: Label) -> Result<i32, LabelError> {
self.label_repository.create_label(label).await
}
}

View File

@ -3,13 +3,13 @@ use std::sync::Arc;
use async_trait::async_trait;
use crate::{
application::{error::post_error::PostError, gateway::label_repository::LabelRepository},
application::{error::label_error::LabelError, gateway::label_repository::LabelRepository},
domain::entity::label::Label,
};
#[async_trait]
pub trait GetAllLabelsUseCase: Send + Sync {
async fn execute(&self) -> Result<Vec<Label>, PostError>;
async fn execute(&self) -> Result<Vec<Label>, LabelError>;
}
pub struct GetAllLabelsUseCaseImpl {
@ -24,7 +24,7 @@ impl GetAllLabelsUseCaseImpl {
#[async_trait]
impl GetAllLabelsUseCase for GetAllLabelsUseCaseImpl {
async fn execute(&self) -> Result<Vec<Label>, PostError> {
async fn execute(&self) -> Result<Vec<Label>, LabelError> {
self.label_repository.get_all_labels().await
}
}

View File

@ -3,13 +3,13 @@ use std::sync::Arc;
use async_trait::async_trait;
use crate::{
application::{error::post_error::PostError, gateway::label_repository::LabelRepository},
application::{error::label_error::LabelError, gateway::label_repository::LabelRepository},
domain::entity::label::Label,
};
#[async_trait]
pub trait UpdateLabelUseCase: Send + Sync {
async fn execute(&self, label: Label) -> Result<(), PostError>;
async fn execute(&self, label: Label) -> Result<(), LabelError>;
}
pub struct UpdateLabelUseCaseImpl {
@ -24,7 +24,7 @@ impl UpdateLabelUseCaseImpl {
#[async_trait]
impl UpdateLabelUseCase for UpdateLabelUseCaseImpl {
async fn execute(&self, label: Label) -> Result<(), PostError> {
async fn execute(&self, label: Label) -> Result<(), LabelError> {
self.label_repository.update_label(label).await
}
}

View File

@ -0,0 +1 @@
pub mod entity;

View File

@ -0,0 +1,2 @@
pub mod color;
pub mod label;

View File

@ -0,0 +1,2 @@
pub mod db;
pub mod web;

View File

@ -0,0 +1,3 @@
pub mod label_db_service_impl;
mod label_record;

View File

@ -4,7 +4,7 @@ use sqlx::{Pool, Postgres};
use crate::{
adapter::gateway::{label_db_mapper::LabelMapper, label_db_service::LabelDbService},
application::error::post_error::PostError,
application::error::label_error::LabelError,
framework::db::label_record::LabelRecord,
};
@ -20,7 +20,7 @@ impl LabelDbServiceImpl {
#[async_trait]
impl LabelDbService for LabelDbServiceImpl {
async fn create_label(&self, label: LabelMapper) -> Result<i32, PostError> {
async fn create_label(&self, label: LabelMapper) -> Result<i32, LabelError> {
let id = sqlx::query_scalar!(
r#"
INSERT INTO label (name, color)
@ -35,16 +35,16 @@ impl LabelDbService for LabelDbServiceImpl {
.map_err(|e| {
if let sqlx::Error::Database(db_err) = &e {
if db_err.constraint() == Some("idx_label_name") {
return PostError::DuplicatedLabelName;
return LabelError::DuplicatedLabelName;
}
}
PostError::Unexpected(DatabaseError(e).into())
LabelError::Unexpected(DatabaseError(e).into())
})?;
Ok(id)
}
async fn update_label(&self, label: LabelMapper) -> Result<(), PostError> {
async fn update_label(&self, label: LabelMapper) -> Result<(), LabelError> {
let affected_rows = sqlx::query!(
r#"
UPDATE label
@ -60,21 +60,21 @@ impl LabelDbService for LabelDbServiceImpl {
.map_err(|e| {
if let sqlx::Error::Database(db_err) = &e {
if db_err.constraint() == Some("idx_label_name") {
return PostError::DuplicatedLabelName;
return LabelError::DuplicatedLabelName;
}
}
PostError::Unexpected(DatabaseError(e).into())
LabelError::Unexpected(DatabaseError(e).into())
})?
.rows_affected();
if affected_rows == 0 {
return Err(PostError::NotFound);
return Err(LabelError::NotFound);
}
Ok(())
}
async fn get_label_by_id(&self, id: i32) -> Result<LabelMapper, PostError> {
async fn get_label_by_id(&self, id: i32) -> Result<LabelMapper, LabelError> {
let record = sqlx::query_as!(
LabelRecord,
r#"
@ -86,15 +86,15 @@ impl LabelDbService for LabelDbServiceImpl {
)
.fetch_optional(&self.db_pool)
.await
.map_err(|e| PostError::Unexpected(DatabaseError(e).into()))?;
.map_err(|e| LabelError::Unexpected(DatabaseError(e).into()))?;
match record {
Some(record) => Ok(record.into_mapper()),
None => Err(PostError::NotFound),
None => Err(LabelError::NotFound),
}
}
async fn get_all_labels(&self) -> Result<Vec<LabelMapper>, PostError> {
async fn get_all_labels(&self) -> Result<Vec<LabelMapper>, LabelError> {
let records = sqlx::query_as!(
LabelRecord,
r#"
@ -106,7 +106,7 @@ impl LabelDbService for LabelDbServiceImpl {
)
.fetch_all(&self.db_pool)
.await
.map_err(|e| PostError::Unexpected(DatabaseError(e).into()))?;
.map_err(|e| LabelError::Unexpected(DatabaseError(e).into()))?;
let mappers = records
.into_iter()

View File

@ -0,0 +1,5 @@
pub mod create_label_handler;
pub mod get_all_labels_handler;
pub mod label_api_doc;
pub mod label_web_routes;
pub mod update_label_handler;

View File

@ -5,43 +5,43 @@ use sentry::integrations::anyhow::capture_anyhow;
use crate::{
adapter::delivery::{
create_label_request_dto::CreateLabelRequestDto, label_response_dto::LabelResponseDto,
post_controller::PostController,
create_label_request_dto::CreateLabelRequestDto, label_controller::LabelController,
label_response_dto::LabelResponseDto,
},
application::error::post_error::PostError,
application::error::label_error::LabelError,
};
#[utoipa::path(
post,
path = "/label",
tag = "post",
tag = "label",
summary = "Create a new label",
responses(
(status = 201, body = LabelResponseDto),
(status = 401, description = LabelError::Unauthorized),
(status = 409, description = LabelError::DuplicatedLabelName),
),
security(
("oauth2" = [])
)
)]
pub async fn create_label_handler(
post_controller: web::Data<dyn PostController>,
label_controller: web::Data<dyn LabelController>,
label_dto: web::Json<CreateLabelRequestDto>,
_: UserId,
) -> impl Responder {
let result = post_controller.create_label(label_dto.into_inner()).await;
let result = label_controller.create_label(label_dto.into_inner()).await;
match result {
Ok(label) => HttpResponse::Created().json(label),
Err(e) => match e {
PostError::Unauthorized => HttpResponse::Unauthorized().finish(),
PostError::DuplicatedLabelName => HttpResponse::Conflict().finish(),
PostError::NotFound
| PostError::InvalidSemanticId
| PostError::DuplicatedSemanticId => {
LabelError::Unauthorized => HttpResponse::Unauthorized().finish(),
LabelError::DuplicatedLabelName => HttpResponse::Conflict().finish(),
LabelError::NotFound => {
capture_anyhow(&anyhow!(e));
HttpResponse::InternalServerError().finish()
}
PostError::Unexpected(e) => {
LabelError::Unexpected(e) => {
capture_anyhow(&e);
HttpResponse::InternalServerError().finish()
}

View File

@ -3,36 +3,32 @@ use anyhow::anyhow;
use sentry::integrations::anyhow::capture_anyhow;
use crate::{
adapter::delivery::{label_response_dto::LabelResponseDto, post_controller::PostController},
application::error::post_error::PostError,
adapter::delivery::{label_controller::LabelController, label_response_dto::LabelResponseDto},
application::error::label_error::LabelError,
};
#[utoipa::path(
get,
path = "/label",
tag = "post",
tag = "label",
summary = "Get all labels",
responses(
(status = 200, body = Vec<LabelResponseDto>)
)
)]
pub async fn get_all_labels_handler(
post_controller: web::Data<dyn PostController>,
label_controller: web::Data<dyn LabelController>,
) -> impl Responder {
let result = post_controller.get_all_labels().await;
let result = label_controller.get_all_labels().await;
match result {
Ok(labels) => HttpResponse::Ok().json(labels),
Err(e) => match e {
PostError::NotFound
| PostError::Unauthorized
| PostError::InvalidSemanticId
| PostError::DuplicatedSemanticId
| PostError::DuplicatedLabelName => {
LabelError::NotFound | LabelError::Unauthorized | LabelError::DuplicatedLabelName => {
capture_anyhow(&anyhow!(e));
HttpResponse::InternalServerError().finish()
}
PostError::Unexpected(e) => {
LabelError::Unexpected(e) => {
capture_anyhow(&e);
HttpResponse::InternalServerError().finish()
}

View File

@ -0,0 +1,14 @@
use crate::framework::web::{create_label_handler, get_all_labels_handler, update_label_handler};
use utoipa::{OpenApi, openapi};
#[derive(OpenApi)]
#[openapi(paths(
create_label_handler::create_label_handler,
update_label_handler::update_label_handler,
get_all_labels_handler::get_all_labels_handler
))]
struct ApiDoc;
pub fn openapi() -> openapi::OpenApi {
ApiDoc::openapi()
}

View File

@ -0,0 +1,15 @@
use actix_web::web;
use crate::framework::web::{
create_label_handler::create_label_handler, get_all_labels_handler::get_all_labels_handler,
update_label_handler::update_label_handler,
};
pub fn configure_label_routes(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/label")
.route("", web::get().to(get_all_labels_handler))
.route("", web::post().to(create_label_handler))
.route("/{id}", web::put().to(update_label_handler)),
);
}

View File

@ -1,51 +1,48 @@
use actix_web::{HttpResponse, Responder, web};
use anyhow::anyhow;
use auth::framework::web::auth_middleware::UserId;
use sentry::integrations::anyhow::capture_anyhow;
use crate::{
adapter::delivery::{
label_response_dto::LabelResponseDto, post_controller::PostController,
label_controller::LabelController, label_response_dto::LabelResponseDto,
update_label_request_dto::UpdateLabelRequestDto,
},
application::error::post_error::PostError,
application::error::label_error::LabelError,
};
#[utoipa::path(
put,
path = "/label/{id}",
tag = "post",
tag = "label",
summary = "Update a label by ID",
responses(
(status = 200, body = LabelResponseDto),
(status = 404, description = "Label not found"),
(status = 401, description = LabelError::Unauthorized),
(status = 404, description = LabelError::NotFound),
(status = 409, description = LabelError::DuplicatedLabelName),
),
security(
("oauth2" = [])
)
)]
pub async fn update_label_handler(
post_controller: web::Data<dyn PostController>,
label_controller: web::Data<dyn LabelController>,
label_dto: web::Json<UpdateLabelRequestDto>,
path: web::Path<i32>,
_: UserId,
) -> impl Responder {
let id = path.into_inner();
let result = post_controller
let result = label_controller
.update_label(id, label_dto.into_inner())
.await;
match result {
Ok(label) => HttpResponse::Ok().json(label),
Err(e) => match e {
PostError::NotFound => HttpResponse::NotFound().finish(),
PostError::Unauthorized => HttpResponse::Unauthorized().finish(),
PostError::DuplicatedLabelName => HttpResponse::Conflict().finish(),
PostError::InvalidSemanticId | PostError::DuplicatedSemanticId => {
capture_anyhow(&anyhow!(e));
HttpResponse::InternalServerError().finish()
}
PostError::Unexpected(e) => {
LabelError::NotFound => HttpResponse::NotFound().finish(),
LabelError::Unauthorized => HttpResponse::Unauthorized().finish(),
LabelError::DuplicatedLabelName => HttpResponse::Conflict().finish(),
LabelError::Unexpected(e) => {
capture_anyhow(&e);
HttpResponse::InternalServerError().finish()
}

View File

@ -0,0 +1,4 @@
pub mod adapter;
pub mod application;
pub mod domain;
pub mod framework;

View File

@ -16,3 +16,4 @@ utoipa.workspace = true
auth.workspace = true
common.workspace = true
label.workspace = true

View File

@ -1,11 +1,6 @@
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

@ -4,28 +4,18 @@ use async_trait::async_trait;
use crate::{
adapter::delivery::{
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_post_use_case::CreatePostUseCase,
get_all_labels_use_case::GetAllLabelsUseCase,
get_all_post_info_use_case::GetAllPostInfoUseCase,
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,
create_post_use_case::CreatePostUseCase, get_all_post_info_use_case::GetAllPostInfoUseCase, get_post_by_id_use_case::GetPostByIdUseCase, get_post_by_semantic_id_use_case::GetPostBySemanticIdUseCase, update_post_use_case::UpdatePostUseCase
},
},
};
use super::{
label_response_dto::LabelResponseDto, post_info_response_dto::PostInfoResponseDto,
post_response_dto::PostResponseDto,
};
use super::{post_info_response_dto::PostInfoResponseDto, post_response_dto::PostResponseDto};
#[async_trait]
pub trait PostController: Send + Sync {
@ -53,19 +43,6 @@ pub trait PostController: Send + Sync {
post: UpdatePostRequestDto,
user_id: i32,
) -> Result<PostResponseDto, PostError>;
async fn create_label(
&self,
label: CreateLabelRequestDto,
) -> Result<LabelResponseDto, PostError>;
async fn update_label(
&self,
id: i32,
label: UpdateLabelRequestDto,
) -> Result<LabelResponseDto, PostError>;
async fn get_all_labels(&self) -> Result<Vec<LabelResponseDto>, PostError>;
}
pub struct PostControllerImpl {
@ -74,9 +51,6 @@ pub struct PostControllerImpl {
get_post_by_semantic_id_use_case: Arc<dyn GetPostBySemanticIdUseCase>,
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>,
}
impl PostControllerImpl {
@ -86,9 +60,6 @@ impl PostControllerImpl {
get_post_by_semantic_id_use_case: Arc<dyn GetPostBySemanticIdUseCase>,
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>,
) -> Self {
Self {
get_all_post_info_use_case,
@ -96,9 +67,6 @@ impl PostControllerImpl {
get_post_by_semantic_id_use_case,
create_post_use_case,
update_post_use_case,
create_label_use_case,
update_label_use_case,
get_all_labels_use_case,
}
}
@ -161,44 +129,6 @@ impl PostController for PostControllerImpl {
}
}
async fn create_label(
&self,
label: CreateLabelRequestDto,
) -> Result<LabelResponseDto, PostError> {
let mut label_entity = label.into_entity();
let id = self
.create_label_use_case
.execute(label_entity.clone())
.await?;
label_entity.id = id;
Ok(LabelResponseDto::from(label_entity))
}
async fn update_label(
&self,
id: i32,
label: UpdateLabelRequestDto,
) -> Result<LabelResponseDto, PostError> {
let label_entity = label.into_entity(id);
self.update_label_use_case
.execute(label_entity.clone())
.await?;
Ok(LabelResponseDto::from(label_entity))
}
async fn get_all_labels(&self) -> Result<Vec<LabelResponseDto>, PostError> {
let result = self.get_all_labels_use_case.execute().await;
result.map(|labels| {
labels
.into_iter()
.map(|label| LabelResponseDto::from(label))
.collect()
})
}
async fn create_post(
&self,
post: CreatePostRequestDto,

View File

@ -1,10 +1,9 @@
use label::adapter::delivery::label_response_dto::LabelResponseDto;
use serde::Serialize;
use utoipa::ToSchema;
use crate::domain::entity::post_info::PostInfo;
use super::label_response_dto::LabelResponseDto;
#[derive(Serialize, ToSchema)]
pub struct PostInfoResponseDto {
pub id: i32,

View File

@ -1,7 +1,3 @@
pub mod color_db_mapper;
pub mod label_db_mapper;
pub mod label_db_service;
pub mod label_repository_impl;
pub mod post_db_mapper;
pub mod post_db_service;
pub mod post_info_db_mapper;

View File

@ -1,6 +1,7 @@
use chrono::{DateTime, NaiveDateTime, Utc};
use label::adapter::gateway::label_db_mapper::LabelMapper;
use crate::{adapter::gateway::label_db_mapper::LabelMapper, domain::entity::post_info::PostInfo};
use crate::domain::entity::post_info::PostInfo;
pub struct PostInfoMapper {
pub id: i32,

View File

@ -6,21 +6,25 @@ pub enum PostError {
Unauthorized,
InvalidSemanticId,
DuplicatedSemanticId,
DuplicatedLabelName,
Unexpected(anyhow::Error),
}
impl Into<String> for PostError {
fn into(self) -> String {
format!("{}", self)
}
}
impl Display for PostError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PostError::NotFound => write!(f, "Post not found"),
PostError::Unauthorized => write!(f, "Unauthorized access to post"),
PostError::Unauthorized => write!(f, "Unauthorized access"),
PostError::InvalidSemanticId => write!(
f,
"Semantic ID shouldn't be numeric and must conform to `^[0-9a-zA-Z_\\-]+$`"
"Semantic ID shouldn't be numeric and must conform to `^[0-9a-zA-Z_-]+$`"
),
PostError::DuplicatedSemanticId => write!(f, "Semantic ID already exists"),
PostError::DuplicatedLabelName => write!(f, "Label name already exists"),
PostError::Unexpected(e) => write!(f, "Unexpected error: {}", e),
}
}

View File

@ -1,2 +1 @@
pub mod label_repository;
pub mod post_repository;

View File

@ -1,11 +0,0 @@
use async_trait::async_trait;
use crate::{application::error::post_error::PostError, domain::entity::label::Label};
#[async_trait]
pub trait LabelRepository: Send + Sync {
async fn create_label(&self, label: Label) -> Result<i32, PostError>;
async fn update_label(&self, label: Label) -> Result<(), PostError>;
async fn get_label_by_id(&self, id: i32) -> Result<Label, PostError>;
async fn get_all_labels(&self) -> Result<Vec<Label>, PostError>;
}

View File

@ -1,8 +1,5 @@
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_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;

View File

@ -1,4 +1,2 @@
pub mod color;
pub mod label;
pub mod post_info;
pub mod post;

View File

@ -1,10 +1,9 @@
use chrono::{DateTime, Utc};
use label::domain::entity::label::Label;
use regex::Regex;
use crate::application::error::post_error::PostError;
use super::label::Label;
pub struct PostInfo {
pub id: i32,
pub semantic_id: String,
@ -21,7 +20,7 @@ impl PostInfo {
return Err(PostError::InvalidSemanticId);
}
let re = Regex::new(r"^[0-9a-zA-Z_\-]+$").unwrap();
let re = Regex::new(r"^[0-9a-zA-Z_-]+$").unwrap();
if !re.is_match(&self.semantic_id) {
return Err(PostError::InvalidSemanticId);
}

View File

@ -1,6 +1,4 @@
pub mod label_db_service_impl;
pub mod post_db_service_impl;
mod label_record;
mod post_info_with_label_record;
mod post_with_label_record;

View File

@ -2,12 +2,13 @@ use std::collections::HashMap;
use async_trait::async_trait;
use common::framework::error::DatabaseError;
use label::adapter::gateway::{color_db_mapper::ColorMapper, label_db_mapper::LabelMapper};
use sqlx::{Pool, Postgres};
use crate::{
adapter::gateway::{
color_db_mapper::ColorMapper, label_db_mapper::LabelMapper, post_db_mapper::PostMapper,
post_db_service::PostDbService, post_info_db_mapper::PostInfoMapper,
post_db_mapper::PostMapper, post_db_service::PostDbService,
post_info_db_mapper::PostInfoMapper,
},
application::error::post_error::PostError,
};

View File

@ -1,10 +1,7 @@
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

@ -18,6 +18,9 @@ use crate::{
summary = "Create a new post",
responses(
(status = 201, body = PostResponseDto),
(status = 400, description = PostError::InvalidSemanticId),
(status = 401, description = PostError::Unauthorized),
(status = 409, description = PostError::DuplicatedSemanticId),
),
security(
("oauth2" = [])
@ -38,7 +41,7 @@ pub async fn create_post_handler(
PostError::Unauthorized => HttpResponse::Unauthorized().finish(),
PostError::InvalidSemanticId => HttpResponse::BadRequest().finish(),
PostError::DuplicatedSemanticId => HttpResponse::Conflict().finish(),
PostError::NotFound | PostError::DuplicatedLabelName => {
PostError::NotFound => {
capture_anyhow(&anyhow!(e));
HttpResponse::InternalServerError().finish()
}

View File

@ -39,8 +39,7 @@ pub async fn get_all_post_info_handler(
PostError::NotFound
| PostError::Unauthorized
| PostError::InvalidSemanticId
| PostError::DuplicatedSemanticId
| PostError::DuplicatedLabelName => {
| PostError::DuplicatedSemanticId => {
capture_anyhow(&anyhow!(e));
HttpResponse::InternalServerError().finish()
}

View File

@ -16,7 +16,8 @@ use crate::{
description = "Only authenticated users can access unpublished posts. Accepts either numeric ID or semantic ID.",
responses (
(status = 200, body = PostResponseDto),
(status = 404, description = "Post not found")
(status = 401, description = PostError::Unauthorized),
(status = 404, description = PostError::NotFound),
)
)]
pub async fn get_post_by_id_handler(
@ -34,9 +35,7 @@ pub async fn get_post_by_id_handler(
Err(e) => match e {
PostError::NotFound => HttpResponse::NotFound().finish(),
PostError::Unauthorized => HttpResponse::Unauthorized().finish(),
PostError::InvalidSemanticId
| PostError::DuplicatedSemanticId
| PostError::DuplicatedLabelName => {
PostError::InvalidSemanticId | PostError::DuplicatedSemanticId => {
capture_anyhow(&anyhow!(e));
HttpResponse::InternalServerError().finish()
}

View File

@ -1,6 +1,5 @@
use crate::framework::web::{
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,
create_post_handler, get_all_post_info_handler, get_post_by_id_handler, update_post_handler,
};
use utoipa::{OpenApi, openapi};
@ -10,9 +9,6 @@ use utoipa::{OpenApi, openapi};
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
))]
struct ApiDoc;

View File

@ -1,11 +1,8 @@
use actix_web::web;
use crate::framework::web::{
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,
create_post_handler::create_post_handler, get_all_post_info_handler::get_all_post_info_handler,
get_post_by_id_handler::get_post_by_id_handler, update_post_handler::update_post_handler,
};
pub fn configure_post_routes(cfg: &mut web::ServiceConfig) {
@ -16,11 +13,4 @@ pub fn configure_post_routes(cfg: &mut web::ServiceConfig) {
.route("/{id}", web::get().to(get_post_by_id_handler))
.route("/{id}", web::put().to(update_post_handler)),
);
cfg.service(
web::scope("/label")
.route("", web::get().to(get_all_labels_handler))
.route("", web::post().to(create_label_handler))
.route("/{id}", web::put().to(update_label_handler)),
);
}

View File

@ -1,5 +1,4 @@
use actix_web::{HttpResponse, Responder, web};
use anyhow::anyhow;
use auth::framework::web::auth_middleware::UserId;
use sentry::integrations::anyhow::capture_anyhow;
@ -18,7 +17,10 @@ use crate::{
summary = "Update a post by ID",
responses(
(status = 200, body = PostResponseDto),
(status = 404, description = "Post not found"),
(status = 400, description = PostError::InvalidSemanticId),
(status = 401, description = PostError::Unauthorized),
(status = 404, description = PostError::NotFound),
(status = 409, description = PostError::DuplicatedSemanticId),
),
security(
("oauth2" = [])
@ -42,10 +44,6 @@ pub async fn update_post_handler(
PostError::Unauthorized => HttpResponse::Unauthorized().finish(),
PostError::DuplicatedSemanticId => HttpResponse::Conflict().finish(),
PostError::InvalidSemanticId => HttpResponse::BadRequest().finish(),
PostError::DuplicatedLabelName => {
capture_anyhow(&anyhow!(e));
HttpResponse::InternalServerError().finish()
}
PostError::Unexpected(e) => {
capture_anyhow(&e);
HttpResponse::InternalServerError().finish()

View File

@ -18,4 +18,5 @@ utoipa-redoc.workspace = true
auth.workspace = true
image.workspace = true
label.workspace = true
post.workspace = true

View File

@ -1,6 +1,7 @@
use actix_web::web;
use auth::framework::web::auth_api_doc;
use image::framework::web::image_api_doc;
use label::framework::web::label_api_doc;
use post::framework::web::post_api_doc;
use utoipa::{
OpenApi,
@ -40,6 +41,7 @@ pub fn configure_api_doc_routes(cfg: &mut web::ServiceConfig) {
let openapi = ApiDoc::openapi()
.merge_from(auth_api_doc::openapi())
.merge_from(image_api_doc::openapi())
.merge_from(label_api_doc::openapi())
.merge_from(post_api_doc::openapi());
cfg.service(Redoc::with_url("/redoc", openapi));

View File

@ -27,25 +27,32 @@ use image::{
storage::image_storage_impl::ImageStorageImpl,
},
};
use label::{
adapter::{
delivery::label_controller::{LabelController, LabelControllerImpl},
gateway::label_repository_impl::LabelRepositoryImpl,
},
application::use_case::{
create_label_use_case::CreateLabelUseCaseImpl,
get_all_labels_use_case::GetAllLabelsUseCaseImpl,
update_label_use_case::UpdateLabelUseCaseImpl,
},
framework::db::label_db_service_impl::LabelDbServiceImpl,
};
use openidconnect::reqwest;
use post::{
adapter::{
delivery::post_controller::{PostController, PostControllerImpl},
gateway::{
label_repository_impl::LabelRepositoryImpl, post_repository_impl::PostRepositoryImpl,
},
gateway::post_repository_impl::PostRepositoryImpl,
},
application::use_case::{
create_label_use_case::CreateLabelUseCaseImpl, create_post_use_case::CreatePostUseCaseImpl,
get_all_labels_use_case::GetAllLabelsUseCaseImpl,
create_post_use_case::CreatePostUseCaseImpl,
get_all_post_info_use_case::GetAllPostInfoUseCaseImpl,
get_post_by_id_use_case::GetFullPostUseCaseImpl,
get_post_by_semantic_id_use_case::GetPostBySemanticIdUseCaseImpl,
update_label_use_case::UpdateLabelUseCaseImpl, update_post_use_case::UpdatePostUseCaseImpl,
},
framework::db::{
label_db_service_impl::LabelDbServiceImpl, post_db_service_impl::PostDbServiceImpl,
update_post_use_case::UpdatePostUseCaseImpl,
},
framework::db::post_db_service_impl::PostDbServiceImpl,
};
use sqlx::{Pool, Postgres};
@ -54,6 +61,7 @@ use crate::configuration::Configuration;
pub struct Container {
pub auth_controller: Arc<dyn AuthController>,
pub image_controller: Arc<dyn ImageController>,
pub label_controller: Arc<dyn LabelController>,
pub post_controller: Arc<dyn PostController>,
}
@ -87,13 +95,27 @@ impl Container {
get_user_use_case,
));
// Post
let post_db_service = Arc::new(PostDbServiceImpl::new(db_pool.clone()));
// Label
let label_db_service = Arc::new(LabelDbServiceImpl::new(db_pool.clone()));
let post_repository = Arc::new(PostRepositoryImpl::new(post_db_service.clone()));
let label_repository = Arc::new(LabelRepositoryImpl::new(label_db_service.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 =
Arc::new(GetAllLabelsUseCaseImpl::new(label_repository.clone()));
let label_controller = Arc::new(LabelControllerImpl::new(
create_label_use_case,
update_label_use_case,
get_all_labels_use_case,
));
// Post
let post_db_service = Arc::new(PostDbServiceImpl::new(db_pool.clone()));
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_post_by_id_use_case =
@ -104,10 +126,6 @@ impl Container {
));
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 =
Arc::new(GetAllLabelsUseCaseImpl::new(label_repository.clone()));
let post_controller = Arc::new(PostControllerImpl::new(
get_all_post_info_use_case,
@ -115,9 +133,6 @@ impl Container {
get_post_by_semantic_id_use_case,
create_post_use_case,
update_post_use_case,
create_label_use_case,
update_label_use_case,
get_all_labels_use_case,
));
// Image
@ -141,6 +156,7 @@ impl Container {
Self {
auth_controller,
image_controller,
label_controller,
post_controller,
}
}

View File

@ -10,6 +10,7 @@ use actix_web::{
};
use auth::framework::web::auth_web_routes::configure_auth_routes;
use image::framework::web::image_web_routes::configure_image_routes;
use label::framework::web::label_web_routes::configure_label_routes;
use openidconnect::reqwest;
use post::framework::web::post_web_routes::configure_post_routes;
use server::{
@ -84,9 +85,11 @@ fn create_app(
.wrap(session_middleware_builder.build())
.app_data(web::Data::from(container.auth_controller))
.app_data(web::Data::from(container.image_controller))
.app_data(web::Data::from(container.label_controller))
.app_data(web::Data::from(container.post_controller))
.configure(configure_api_doc_routes)
.configure(configure_auth_routes)
.configure(configure_image_routes)
.configure(configure_label_routes)
.configure(configure_post_routes)
}

View File

@ -1,4 +1,4 @@
import { Color } from '$lib/post/domain/entity/color';
import { Color } from '$lib/label/domain/entity/color';
import z from 'zod';
export const ColorResponseSchema = z.object({

View File

@ -1,5 +1,5 @@
import { ColorResponseDto, ColorResponseSchema } from '$lib/post/adapter/gateway/colorResponseDto';
import { Label } from '$lib/post/domain/entity/label';
import { ColorResponseDto, ColorResponseSchema } from '$lib/label/adapter/gateway/colorResponseDto';
import { Label } from '$lib/label/domain/entity/label';
import { z } from 'zod';
export const LabelResponseSchema = z.object({

View File

@ -1,4 +1,4 @@
import type { Color } from '$lib/post/domain/entity/color';
import type { Color } from '$lib/label/domain/entity/color';
export class ColorViewModel {
readonly red: number;

View File

@ -1,8 +1,8 @@
import {
ColorViewModel,
type DehydratedColorProps,
} from '$lib/post/adapter/presenter/colorViewModel';
import type { Label } from '$lib/post/domain/entity/label';
} from '$lib/label/adapter/presenter/colorViewModel';
import type { Label } from '$lib/label/domain/entity/label';
export class LabelViewModel {
readonly id: number;

View File

@ -1,4 +1,4 @@
import type { Color } from '$lib/post/domain/entity/color';
import type { Color } from '$lib/label/domain/entity/color';
export class Label {
readonly id: number;

View File

@ -1,5 +1,5 @@
<script lang="ts">
import type { LabelViewModel } from '$lib/post/adapter/presenter/labelViewModel';
import type { LabelViewModel } from '$lib/label/adapter/presenter/labelViewModel';
const { label }: { label: LabelViewModel } = $props();
</script>

View File

@ -1,4 +1,4 @@
import { LabelResponseDto, LabelResponseSchema } from '$lib/post/adapter/gateway/labelResponseDto';
import { LabelResponseDto, LabelResponseSchema } from '$lib/label/adapter/gateway/labelResponseDto';
import { PostInfo } from '$lib/post/domain/entity/postInfo';
import z from 'zod';

View File

@ -1,7 +1,7 @@
import {
LabelViewModel,
type DehydratedLabelProps,
} from '$lib/post/adapter/presenter/labelViewModel';
} from '$lib/label/adapter/presenter/labelViewModel';
import type { PostInfo } from '$lib/post/domain/entity/postInfo';
export class PostInfoViewModel {

View File

@ -1,4 +1,4 @@
import type { Label } from '$lib/post/domain/entity/label';
import type { Label } from '$lib/label/domain/entity/label';
export class PostInfo {
readonly id: number;

View File

@ -1,6 +1,6 @@
<script lang="ts">
import type { PostInfoViewModel } from '$lib/post/adapter/presenter/postInfoViewModel';
import PostLabel from '$lib/post/framework/ui/PostLabel.svelte';
import PostLabel from '$lib/label/framework/ui/PostLabel.svelte';
const { postInfo }: { postInfo: PostInfoViewModel } = $props();
</script>

View File

@ -10,7 +10,7 @@
import CreatePostDialog, {
type CreatePostDialogFormParams,
} from '$lib/post/framework/ui/CreatePostDialog.svelte';
import PostLabel from '$lib/post/framework/ui/PostLabel.svelte';
import PostLabel from '$lib/label/framework/ui/PostLabel.svelte';
import { getContext, onMount } from 'svelte';
import { toast } from 'svelte-sonner';

View File

@ -1,6 +1,6 @@
<script lang="ts">
import type { LabelViewModel } from '$lib/post/adapter/presenter/labelViewModel';
import PostLabel from '$lib/post/framework/ui/PostLabel.svelte';
import type { LabelViewModel } from '$lib/label/adapter/presenter/labelViewModel';
import PostLabel from '$lib/label/framework/ui/PostLabel.svelte';
const { labels }: { labels: readonly LabelViewModel[] } = $props();
</script>