feat(dimentorin): complete articles module - HTTP layer, routes, categories fix, seed
This commit is contained in:
@@ -2,3 +2,8 @@ pub mod article;
|
||||
pub mod article_types;
|
||||
pub mod repository;
|
||||
pub mod service;
|
||||
|
||||
pub use repository::ArticleRepository;
|
||||
pub use service::ArticleService;
|
||||
pub use article::ArticleEntity;
|
||||
pub use article_types::{ArticleDetail, ArticleListItem, CreateArticleCommand};
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod request;
|
||||
pub mod response;
|
||||
|
||||
pub use request::CreateArticleRequestDto;
|
||||
pub use response::{ArticleDetailDto, ArticleListItemDto};
|
||||
@@ -0,0 +1,43 @@
|
||||
use crate::articles::domain::article_types::CreateArticleCommand;
|
||||
use imphnen_libs::ZodValidate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use zod_rs::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, ZodSchema)]
|
||||
pub struct CreateArticleRequestDto {
|
||||
#[zod(min_length(3), max_length(200))]
|
||||
pub title: String,
|
||||
#[zod(min_length(3), max_length(200))]
|
||||
pub slug: String,
|
||||
#[zod(min_length(1), max_length(100))]
|
||||
pub category: String,
|
||||
#[zod(min_length(3), max_length(500))]
|
||||
pub excerpt: String,
|
||||
#[zod(min_length(10))]
|
||||
pub content: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cover_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub author_name: Option<String>,
|
||||
}
|
||||
|
||||
impl ZodValidate for CreateArticleRequestDto {
|
||||
fn zod_validate(value: &serde_json::Value) -> Result<Self, String> {
|
||||
Self::validate_and_parse(value).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CreateArticleRequestDto> for CreateArticleCommand {
|
||||
fn from(dto: CreateArticleRequestDto) -> Self {
|
||||
Self {
|
||||
title: dto.title,
|
||||
slug: dto.slug,
|
||||
category: dto.category,
|
||||
excerpt: dto.excerpt,
|
||||
content: dto.content,
|
||||
cover_url: dto.cover_url,
|
||||
author_name: dto.author_name,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
use crate::articles::domain::article_types::{ArticleDetail, ArticleListItem};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ArticleListItemDto {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
pub category: String,
|
||||
pub excerpt: String,
|
||||
pub cover_url: Option<String>,
|
||||
pub author_name: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
impl From<ArticleListItem> for ArticleListItemDto {
|
||||
fn from(a: ArticleListItem) -> Self {
|
||||
Self {
|
||||
id: a.id.to_string(),
|
||||
title: a.title,
|
||||
slug: a.slug,
|
||||
category: a.category,
|
||||
excerpt: a.excerpt,
|
||||
cover_url: a.cover_url,
|
||||
author_name: a.author_name,
|
||||
created_at: a.created_at.to_rfc3339(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ArticleDetailDto {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
pub category: String,
|
||||
pub excerpt: String,
|
||||
pub content: String,
|
||||
pub cover_url: Option<String>,
|
||||
pub author_name: Option<String>,
|
||||
pub is_published: bool,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl From<ArticleDetail> for ArticleDetailDto {
|
||||
fn from(a: ArticleDetail) -> Self {
|
||||
Self {
|
||||
id: a.id.to_string(),
|
||||
title: a.title,
|
||||
slug: a.slug,
|
||||
category: a.category,
|
||||
excerpt: a.excerpt,
|
||||
content: a.content,
|
||||
cover_url: a.cover_url,
|
||||
author_name: a.author_name,
|
||||
is_published: a.is_published,
|
||||
created_at: a.created_at.to_rfc3339(),
|
||||
updated_at: a.updated_at.to_rfc3339(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
pub mod mutation_handlers;
|
||||
pub mod query_handlers;
|
||||
|
||||
pub use mutation_handlers::post_create_article;
|
||||
pub use query_handlers::{
|
||||
get_article_by_id, get_article_by_slug, get_article_categories,
|
||||
get_articles_list,
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
use super::super::dto::{ArticleDetailDto, CreateArticleRequestDto};
|
||||
use crate::articles::domain::ArticleService;
|
||||
use axum::{response::IntoResponse, Extension};
|
||||
use imphnen_libs::{AppState, ValidatedJson};
|
||||
use imphnen_utils::{ApiSuccess, AppError};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/v1/dimentorin/articles/create",
|
||||
request_body = CreateArticleRequestDto,
|
||||
responses(
|
||||
(status = 201, description = "Article created successfully", body = ArticleDetailDto),
|
||||
(status = 400, description = "Invalid request"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
tag = "Articles",
|
||||
security(("Bearer" = []))
|
||||
)]
|
||||
pub async fn post_create_article(
|
||||
Extension(_state): Extension<AppState>,
|
||||
Extension(service): Extension<Arc<dyn ArticleService>>,
|
||||
ValidatedJson(dto): ValidatedJson<CreateArticleRequestDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let detail = service.create(dto.into()).await?;
|
||||
Ok(ApiSuccess(ArticleDetailDto::from(detail)))
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
use super::super::dto::{ArticleDetailDto, ArticleListItemDto};
|
||||
use crate::articles::domain::ArticleService;
|
||||
use axum::{
|
||||
extract::{Path, Query},
|
||||
response::IntoResponse,
|
||||
Extension,
|
||||
};
|
||||
use imphnen_libs::AppState;
|
||||
use imphnen_utils::{ApiPaginated, ApiSuccess, AppError};
|
||||
use paginator_utils::{PaginatorResponse, PaginatorResponseMeta};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ArticleListQuery {
|
||||
pub page: Option<u64>,
|
||||
pub per_page: Option<u64>,
|
||||
pub category: Option<String>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v1/dimentorin/articles",
|
||||
params(
|
||||
("page" = Option<u64>, Query, description = "Page number"),
|
||||
("per_page" = Option<u64>, Query, description = "Items per page"),
|
||||
("category" = Option<String>, Query, description = "Filter by category"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Articles retrieved successfully", body = Vec<ArticleListItemDto>),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
tag = "Articles"
|
||||
)]
|
||||
pub async fn get_articles_list(
|
||||
Extension(_state): Extension<AppState>,
|
||||
Extension(service): Extension<Arc<dyn ArticleService>>,
|
||||
Query(q): Query<ArticleListQuery>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let page = q.page.unwrap_or(1).max(1);
|
||||
let per_page = q.per_page.unwrap_or(10).clamp(1, 100);
|
||||
let result = service.list(page, per_page, q.category).await?;
|
||||
let mapped = PaginatorResponse {
|
||||
data: result.data.into_iter().map(ArticleListItemDto::from).collect(),
|
||||
meta: result.meta,
|
||||
};
|
||||
Ok(ApiPaginated(mapped))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v1/dimentorin/articles/{id}",
|
||||
params(("id" = String, Path, description = "Article ID")),
|
||||
responses(
|
||||
(status = 200, description = "Article retrieved successfully", body = ArticleDetailDto),
|
||||
(status = 404, description = "Article not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
tag = "Articles"
|
||||
)]
|
||||
pub async fn get_article_by_id(
|
||||
Extension(_state): Extension<AppState>,
|
||||
Extension(service): Extension<Arc<dyn ArticleService>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let uuid = Uuid::parse_str(&id)
|
||||
.map_err(|_| AppError::BadRequestError("Invalid article ID".to_string()))?;
|
||||
let dto = ArticleDetailDto::from(service.get_by_id(uuid).await?);
|
||||
Ok(ApiSuccess(dto))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v1/dimentorin/articles/slug/{slug}",
|
||||
params(("slug" = String, Path, description = "Article slug")),
|
||||
responses(
|
||||
(status = 200, description = "Article retrieved successfully", body = ArticleDetailDto),
|
||||
(status = 404, description = "Article not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
tag = "Articles"
|
||||
)]
|
||||
pub async fn get_article_by_slug(
|
||||
Extension(_state): Extension<AppState>,
|
||||
Extension(service): Extension<Arc<dyn ArticleService>>,
|
||||
Path(slug): Path<String>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let dto = ArticleDetailDto::from(service.get_by_slug(&slug).await?);
|
||||
Ok(ApiSuccess(dto))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v1/dimentorin/articles/categories",
|
||||
responses(
|
||||
(status = 200, description = "Article categories retrieved successfully"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
tag = "Articles"
|
||||
)]
|
||||
pub async fn get_article_categories(
|
||||
Extension(_state): Extension<AppState>,
|
||||
Extension(service): Extension<Arc<dyn ArticleService>>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let cats = service.categories().await?;
|
||||
Ok(ApiSuccess(cats))
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod dto;
|
||||
pub mod handlers;
|
||||
pub mod routes;
|
||||
@@ -0,0 +1,37 @@
|
||||
use super::handlers::{
|
||||
get_article_by_id, get_article_by_slug, get_article_categories,
|
||||
get_articles_list, post_create_article,
|
||||
};
|
||||
use crate::articles::application::ArticleServiceImpl;
|
||||
use crate::articles::domain::ArticleService;
|
||||
use crate::articles::infrastructure::persistence::PostgresArticleRepository;
|
||||
use axum::{Extension, Router, routing::{get, post}};
|
||||
use imphnen_libs::AppState;
|
||||
use sea_orm::DatabaseConnection;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn build_service(db: DatabaseConnection) -> Arc<dyn ArticleService> {
|
||||
let repo = Arc::new(PostgresArticleRepository::new(db));
|
||||
Arc::new(ArticleServiceImpl::new(repo))
|
||||
}
|
||||
|
||||
pub fn articles_public_routes(db: DatabaseConnection) -> Router {
|
||||
let service = build_service(db);
|
||||
Router::new()
|
||||
.route("/articles", get(get_articles_list))
|
||||
.route("/articles/categories", get(get_article_categories))
|
||||
.route("/articles/slug/{slug}", get(get_article_by_slug))
|
||||
.route("/articles/{id}", get(get_article_by_id))
|
||||
.layer(Extension(service))
|
||||
}
|
||||
|
||||
pub fn articles_protected_routes(
|
||||
db: DatabaseConnection,
|
||||
state: Arc<AppState>,
|
||||
) -> Router {
|
||||
let service = build_service(db);
|
||||
Router::new()
|
||||
.route("/articles/create", post(post_create_article))
|
||||
.layer(Extension(service))
|
||||
.layer(Extension((*state).clone()))
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod http;
|
||||
pub mod persistence;
|
||||
@@ -1,2 +1,4 @@
|
||||
pub mod postgres_article_queries;
|
||||
pub mod postgres_article_repository;
|
||||
pub mod postgres_article_repository;
|
||||
|
||||
pub use postgres_article_repository::PostgresArticleRepository;
|
||||
+10
-4
@@ -5,7 +5,9 @@ use imphnen_entities::seaorm::common::articles::{
|
||||
use imphnen_utils::AppError;
|
||||
use paginator_utils::{PaginatorResponse, PaginatorResponseMeta};
|
||||
use sea_orm::prelude::*;
|
||||
use sea_orm::{EntityTrait, Order, PaginatorTrait, QueryFilter, QueryOrder};
|
||||
use sea_orm::{
|
||||
EntityTrait, Order, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub fn model_to_entity(model: ArticleModel) -> ArticleEntity {
|
||||
@@ -49,7 +51,7 @@ pub async fn find_all_paginated(
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
|
||||
let data = articles.into_iter().map(model_to_entity).collect();
|
||||
let meta = PaginatorResponseMeta::new(page, per_page, total as u32);
|
||||
let meta = PaginatorResponseMeta::new(page as u32, per_page as u32, total as u32);
|
||||
Ok(PaginatorResponse { data, meta })
|
||||
}
|
||||
|
||||
@@ -81,12 +83,16 @@ pub async fn find_by_slug(
|
||||
}
|
||||
|
||||
pub async fn find_categories(db: &Arc<DatabaseConnection>) -> Result<Vec<String>, AppError> {
|
||||
let rows = ArticlesEntity::find()
|
||||
let rows: Vec<serde_json::Value> = ArticlesEntity::find()
|
||||
.select_only()
|
||||
.column(ArticleColumn::Category)
|
||||
.distinct()
|
||||
.into_json()
|
||||
.all(db.as_ref())
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(rows.into_iter().map(|r| r.category).collect())
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter_map(|r| r["category"].as_str().map(|s| s.to_string()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
pub mod articles;
|
||||
pub mod mentors;
|
||||
pub mod sessions;
|
||||
|
||||
pub use articles::{articles_protected_routes, articles_public_routes};
|
||||
pub use mentors::{mentors_protected_routes, mentors_public_routes};
|
||||
pub use sessions::{sessions_protected_routes, sessions_public_routes};
|
||||
|
||||
Reference in New Issue
Block a user