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
+40
View File
@@ -0,0 +1,40 @@
#![allow(clippy::all)]
use chrono::Utc;
use imphnen_entities::seaorm::common::articles::{
ActiveModel as ArticleActiveModel, Entity as ArticlesEntity,
};
use imphnen_libs::postgres::PostgresConfig;
use sea_orm::{ActiveModelTrait, ActiveValue, Database};
use uuid::Uuid;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = PostgresConfig::from_env()?;
let db = Database::connect(&config.database_url).await?;
let articles = vec![
("Cara Memulai Karier di UI/UX Design", "cara-memulai-karier-ui-ux-design", "UI/UX & Design", "Panduan lengkap untuk masuk ke dunia UI/UX design, dari skill yang dibutuhkan hingga portofolio.", "Lorem ipsum dolor sit amet, consectetur adipiscing elit. UI/UX design adalah bidang yang menjanjikan. Artikel ini membahas langkah awal memulai karier sebagai UI/UX designer, tools yang wajib dikuasai seperti Figma, serta cara membangun portofolio yang menarik bagi perekrut."),
("Belajar Rust: Panduan Pemula 2026", "belajar-rust-panduan-pemula-2026", "Software/Web Dev", "Bahasa pemrograman Rust sedang naik daun. Pelajari konsep ownership dan borrow checker.", "Rust adalah bahasa pemrograman yang fokus pada performa dan keamanan memori. Dalam artikel ini kita membahas ownership, borrowing, dan cara setup environment Rust di Linux dan Windows, serta contoh project sederhana."),
("Mengenal Machine Learning untuk Data Analyst", "mengenal-machine-learning-data-analyst", "Data & AI", "Peran Data Analyst berevolusi dengan hadirnya machine learning. Simak panduannya.", "Machine learning membuka peluang besar bagi data analyst. Artikel ini menjelaskan perbedaan data analysis dan machine learning, serta roadmap belajar dari Python, pandas, sampai scikit-learn."),
];
for (title, slug, category, excerpt, content) in articles {
let am = ArticleActiveModel {
id: ActiveValue::Set(Uuid::new_v4()),
title: ActiveValue::Set(title.to_string()),
slug: ActiveValue::Set(slug.to_string()),
category: ActiveValue::Set(category.to_string()),
excerpt: ActiveValue::Set(excerpt.to_string()),
content: ActiveValue::Set(content.to_string()),
cover_url: ActiveValue::Set(None),
author_name: ActiveValue::Set(Some("IMPHNEN Editorial".to_string())),
is_published: ActiveValue::Set(true),
created_at: ActiveValue::Set(Utc::now()),
updated_at: ActiveValue::Set(Utc::now()),
};
am.insert(&db).await?;
println!("✅ Inserted article: {}", slug);
}
println!("🟢 All articles seeded");
Ok(())
}
@@ -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};
+7 -2
View File
@@ -6,8 +6,8 @@ use imphnen_cms::{
roadmap_public_routes, testimonials_protected_routes, testimonials_public_routes,
};
use imphnen_dimentorin::{
mentors_protected_routes, mentors_public_routes, sessions_protected_routes,
sessions_public_routes,
articles_protected_routes, articles_public_routes, mentors_protected_routes,
mentors_public_routes, sessions_protected_routes, sessions_public_routes,
};
use imphnen_gacha::gacha_router;
use imphnen_hackathon::hackathon_router;
@@ -74,10 +74,15 @@ pub async fn gateway_service(postgres_clients: PostgresClients) -> Router {
let dimentorin_routes = Router::new()
.merge(mentors_public_routes(db.clone(), Arc::clone(&state_arc)))
.merge(sessions_public_routes(db.clone()))
.merge(articles_public_routes(db.clone()))
.merge(
Router::new()
.merge(mentors_protected_routes(db.clone(), Arc::clone(&state_arc)))
.merge(sessions_protected_routes(db.clone(), Arc::clone(&state_arc)))
.merge(articles_protected_routes(
db.clone(),
Arc::clone(&state_arc),
))
.layer(from_fn(auth_middleware)),
);