feat(dimentorin): complete articles module - HTTP layer, routes, categories fix, seed

This commit is contained in:
asepharyana
2026-08-04 19:16:45 +07:00
parent 7d1078f52a
commit 1d34d29b0b
15 changed files with 363 additions and 7 deletions
@@ -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;
@@ -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())
}
+2
View File
@@ -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};