refactor: migrate to clean architecture with trait-based DI (v0.2.0)

Complete architectural overhaul across all 12 crates:

- Replace validator crate with zod-rs for all DTO validation
- Replace manual pagination with paginator-rs/paginator-sea-orm
- Migrate all modules (iam, cms, gacha, dimentorin) to clean architecture:
  domain → application → infrastructure layers
- Introduce trait-based DI (Arc<dyn Trait>) at every layer for repositories and services
- Delete all v1/ legacy SurrealDB-era code across every crate
- Replace opaque response helpers with typed IntoResponse structs (ApiSuccess, ApiCreated, ApiPaginated, ApiMessage)
- Remove dual_mode_repository, migration_validation_errors, validator.rs dead code
- Zero cargo clippy warnings; release build clean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
maulanasdqn
2026-04-02 13:39:52 +07:00
co-authored by Claude Sonnet 4.6
parent 1b3366d735
commit e432a1a743
379 changed files with 9013 additions and 30532 deletions
+6 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "imphnen-utils"
version = "0.1.0"
version = "0.2.0"
edition = "2024"
[dependencies]
@@ -13,7 +13,11 @@ anyhow.workspace = true
axum-test.workspace = true
serde.workspace = true
serde_json.workspace = true
validator.workspace = true
zod-rs.workspace = true
paginator-rs.workspace = true
paginator-utils.workspace = true
paginator-sea-orm.workspace = true
paginator-axum.workspace = true
strum.workspace = true
strum_macros.workspace = true
uuid.workspace = true
+20 -1
View File
@@ -1,5 +1,10 @@
use axum::http::StatusCode;
use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Serialize;
use serde_json::json;
#[derive(Debug, Serialize)]
pub enum AppError {
@@ -92,4 +97,18 @@ impl From<uuid::Error> for AppError {
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let status = self.status_code();
(
status,
Json(json!({
"message": self.to_string(),
"version": env!("CARGO_PKG_VERSION"),
})),
)
.into_response()
}
}
pub type Result<T, E = AppError> = std::result::Result<T, E>;
+2 -48
View File
@@ -1,64 +1,18 @@
pub mod csrf_token;
pub mod pagination;
pub mod errors;
pub mod extract_email;
pub mod extract_ip;
pub mod generate_date;
pub mod generate_otp;
pub mod logger;
pub mod migration_validation_errors;
pub mod response_format;
pub mod sanitization;
pub mod validator;
// Re-export commonly used functions
pub use extract_email::{extract_email, extract_email_async};
pub use extract_ip::extract_real_ip;
pub use generate_date::get_iso_date;
pub use response_format::{success_response, success_created_response, success_list_response, common_response, error_response};
pub use validator::validate_request;
pub use response_format::{ApiSuccess, ApiCreated, ApiPaginated, ApiMessage};
pub use sanitization::{sanitize_html, sanitize_dangerous_patterns, sanitize_filename, sanitize_user_text, normalize_whitespace, sanitize_email, sanitize_url};
pub use errors::{AppError, Result};
// Add missing utility functions for database operations
pub fn make_thing(_resource: &str, id: &str) -> String {
id.to_string()
}
pub fn make_thing_from_enum(_resource_enum: &str, id: &str) -> String {
id.to_string()
}
/// A compatibility wrapper for SurrealDB's `Thing` type
/// Many test helpers and older modules use `Thing::from((resource, id))`.
/// Provide a small compatibility struct that can be constructed like that and
/// converted to a String so code will compile with PostgreSQL-backed storage.
#[derive(Clone, Debug)]
pub struct Thing(pub String);
impl Thing {
pub fn from((_resource, id): (&str, &str)) -> Self {
Thing(id.to_string())
}
}
impl From<Thing> for String {
fn from(t: Thing) -> Self {
t.0
}
}
use sea_orm::{DatabaseConnection, EntityTrait, QueryFilter, ColumnTrait};
use imphnen_entities::seaorm::auth::users::Entity as UsersEntity;
/// Get user ID from email address
pub async fn get_user_id_from_email(email: &str, db: &DatabaseConnection) -> Result<String> {
let user = UsersEntity::find()
.filter(imphnen_entities::seaorm::auth::users::Column::Email.eq(email))
.one(db)
.await?;
match user {
Some(u) => Ok(u.id.to_string()),
None => Err(AppError::NotFoundError("User not found".to_string())),
}
}
@@ -1,220 +0,0 @@
//! Custom error types for migration validation operations
use std::fmt;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use imphnen_entities::seaorm::common::enums::ResourceEnum;
/// Detailed validation error information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationErrorDetails {
pub record_id: Uuid,
pub field: String,
pub expected_value: Option<serde_json::Value>,
pub actual_value: Option<serde_json::Value>,
pub error_message: String,
pub timestamp: chrono::DateTime<chrono::Utc>,
}
impl ValidationErrorDetails {
/// Create a new validation error details instance
pub fn new(
record_id: Uuid,
field: String,
error_message: String,
) -> Self {
Self {
record_id,
field,
expected_value: None,
actual_value: None,
error_message,
timestamp: chrono::Utc::now(),
}
}
/// Create a new validation error details instance with value comparison
pub fn with_values(
record_id: Uuid,
field: String,
expected_value: serde_json::Value,
actual_value: serde_json::Value,
error_message: String,
) -> Self {
Self {
record_id,
field,
expected_value: Some(expected_value),
actual_value: Some(actual_value),
error_message,
timestamp: chrono::Utc::now(),
}
}
}
/// Migration validation error type
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum MigrationValidationError {
/// Resource not found in one of the databases
RecordNotFound {
resource_type: ResourceEnum,
record_id: Uuid,
database: String,
},
/// Data mismatch between databases
DataMismatch {
resource_type: ResourceEnum,
record_id: Uuid,
errors: Vec<ValidationErrorDetails>,
},
/// Schema mismatch between databases
SchemaMismatch {
resource_type: ResourceEnum,
missing_fields: Vec<String>,
extra_fields: Vec<String>,
},
/// Validation failed for a specific record
ValidationFailed {
resource_type: ResourceEnum,
record_id: Uuid,
error: String,
},
/// Database connection error
DatabaseConnectionError {
database: String,
error: String,
},
/// Query execution error
QueryExecutionError {
resource_type: ResourceEnum,
database: String,
error: String,
},
/// Conversion error between database models
ModelConversionError {
resource_type: ResourceEnum,
error: String,
},
/// Validation timeout
ValidationTimeout {
resource_type: ResourceEnum,
duration: String,
},
/// Partial validation completed (some records failed)
PartialValidation {
resource_type: ResourceEnum,
total_records: i32,
failed_records: i32,
errors: Vec<ValidationErrorDetails>,
},
/// Unsupported validation operation
UnsupportedOperation {
resource_type: ResourceEnum,
operation: String,
},
/// Validation skipped for resource
ValidationSkipped {
resource_type: ResourceEnum,
reason: String,
},
}
impl fmt::Display for MigrationValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
MigrationValidationError::RecordNotFound { resource_type, record_id, database } => {
write!(f, "Record not found in {} for resource {}: {}", database, resource_type.as_str(), record_id)
}
MigrationValidationError::DataMismatch { resource_type, record_id, errors } => {
write!(f, "Data mismatch in resource {} for record {}: {} errors", resource_type.as_str(), record_id, errors.len())
}
MigrationValidationError::SchemaMismatch { resource_type, missing_fields, extra_fields } => {
write!(f, "Schema mismatch in resource {}: {} missing fields, {} extra fields", resource_type.as_str(), missing_fields.len(), extra_fields.len())
}
MigrationValidationError::ValidationFailed { resource_type, record_id, error } => {
write!(f, "Validation failed for resource {} record {}: {}", resource_type.as_str(), record_id, error)
}
MigrationValidationError::DatabaseConnectionError { database, error } => {
write!(f, "{} connection error: {}", database, error)
}
MigrationValidationError::QueryExecutionError { resource_type, database, error } => {
write!(f, "Query execution error in {} for resource {}: {}", database, resource_type.as_str(), error)
}
MigrationValidationError::ModelConversionError { resource_type, error } => {
write!(f, "Model conversion error for resource {}: {}", resource_type.as_str(), error)
}
MigrationValidationError::ValidationTimeout { resource_type, duration } => {
write!(f, "Validation timeout for resource {} after {}: {}", resource_type.as_str(), duration, duration)
}
MigrationValidationError::PartialValidation { resource_type, total_records, failed_records, errors: _ } => {
write!(f, "Partial validation for resource {}: {}/{} records failed ({:.1}%)", resource_type.as_str(), failed_records, total_records, (*failed_records as f64 / *total_records as f64) * 100.0)
}
MigrationValidationError::UnsupportedOperation { resource_type, operation } => {
write!(f, "Unsupported operation {} for resource {}", operation, resource_type.as_str())
}
MigrationValidationError::ValidationSkipped { resource_type, reason } => {
write!(f, "Validation skipped for resource {}: {}", resource_type.as_str(), reason)
}
}
}
}
impl std::error::Error for MigrationValidationError {}
/// Result type for migration validation operations
pub type MigrationValidationResult<T> = Result<T, MigrationValidationError>;
/// Validation summary for a resource
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationSummary {
pub resource_type: ResourceEnum,
pub status: String,
pub total_records: i32,
pub validated_records: i32,
pub failed_records: i32,
pub skipped_records: i32,
pub start_time: chrono::DateTime<chrono::Utc>,
pub end_time: chrono::DateTime<chrono::Utc>,
pub duration: String,
pub error_count: i32,
pub warning_count: i32,
pub details_url: Option<String>,
}
impl ValidationSummary {
/// Create a new validation summary
pub fn new(resource_type: ResourceEnum, status: String) -> Self {
let now = chrono::Utc::now();
Self {
resource_type,
status,
total_records: 0,
validated_records: 0,
failed_records: 0,
skipped_records: 0,
start_time: now,
end_time: now,
duration: "0s".to_string(),
error_count: 0,
warning_count: 0,
details_url: None,
}
}
/// Calculate and set duration from start to end time
pub fn calculate_duration(&mut self) {
let duration = self.end_time.signed_duration_since(self.start_time);
self.duration = format!("{}s", duration.num_seconds());
}
}
+4
View File
@@ -0,0 +1,4 @@
pub use paginator_axum::PaginationQuery;
pub use paginator_rs::{PaginatorBuilder, PaginationParams};
pub use paginator_utils::{PaginatorResponse, PaginatorResponseMeta};
pub use paginator_sea_orm::paginate_with_sort;
+91 -88
View File
@@ -1,88 +1,91 @@
//! Standardized response formatting utilities.
//!
//! This module provides consistent response formatting for API endpoints,
//! including success responses, error responses, and list responses with
//! configurable versioning from Cargo.toml.
use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Serialize;
use serde_json::json;
use imphnen_entities::{ResponseListSuccessDto, ResponseSuccessDto};
use imphnen_entities::error_dto::error::Error;
use crate::errors::AppError;
// Convert from imphnen_entities::Error to AppError
impl From<Error> for AppError {
fn from(error: Error) -> Self {
match error {
Error::Db(detail) => AppError::InternalServerError(format!("Database error: {detail}")),
Error::Anyhow(detail) => AppError::InternalServerError(format!("Internal server error: {detail}")),
Error::StatusCode(status) => AppError::InternalServerError(format!("HTTP error: {status}")),
Error::Auth(detail) => AppError::AuthenticationError(format!("Authentication error: {detail}")),
Error::Validation(detail) => AppError::ValidationError(format!("Validation error: {detail}")),
}
}
}
pub fn success_response<T: Serialize>(params: ResponseSuccessDto<T>) -> Response {
(
StatusCode::OK,
Json(json!({
"data": params.data,
"version": env!("CARGO_PKG_VERSION"),
})),
)
.into_response()
}
pub fn success_list_response<T: Serialize>(
params: ResponseListSuccessDto<T>,
) -> Response {
(
StatusCode::OK,
Json(json!({
"data": params.data,
"meta": params.meta,
"version": env!("CARGO_PKG_VERSION"),
})),
)
.into_response()
}
pub fn common_response(status: StatusCode, message: &str) -> Response {
(
status,
Json(json!({
"message": message,
"version": env!("CARGO_PKG_VERSION"),
})),
)
.into_response()
}
pub fn error_response(error: AppError) -> Response {
(
error.status_code(),
Json(json!({
"error": error.message(),
"version": env!("CARGO_PKG_VERSION"),
})),
)
.into_response()
}
pub fn success_created_response<T: Serialize>(params: ResponseSuccessDto<T>) -> Response {
(
StatusCode::CREATED,
Json(json!({
"data": params.data,
"version": env!("CARGO_PKG_VERSION"),
})),
)
.into_response()
}
use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Serialize;
use serde_json::json;
use paginator_utils::PaginatorResponse;
use imphnen_entities::error_dto::error::Error;
use crate::errors::AppError;
impl From<Error> for AppError {
fn from(error: Error) -> Self {
match error {
Error::Db(detail) => AppError::InternalServerError(format!("Database error: {detail}")),
Error::Anyhow(detail) => AppError::InternalServerError(format!("Internal server error: {detail}")),
Error::StatusCode(status) => AppError::InternalServerError(format!("HTTP error: {status}")),
Error::Auth(detail) => AppError::AuthenticationError(format!("Authentication error: {detail}")),
Error::Validation(detail) => AppError::ValidationError(format!("Validation error: {detail}")),
}
}
}
pub struct ApiSuccess<T: Serialize>(pub T);
impl<T: Serialize> IntoResponse for ApiSuccess<T> {
fn into_response(self) -> Response {
(
StatusCode::OK,
Json(json!({ "data": self.0, "version": env!("CARGO_PKG_VERSION") })),
)
.into_response()
}
}
pub struct ApiCreated<T: Serialize>(pub T);
impl<T: Serialize> IntoResponse for ApiCreated<T> {
fn into_response(self) -> Response {
(
StatusCode::CREATED,
Json(json!({ "data": self.0, "version": env!("CARGO_PKG_VERSION") })),
)
.into_response()
}
}
pub struct ApiPaginated<T: Serialize>(pub PaginatorResponse<T>);
impl<T: Serialize> IntoResponse for ApiPaginated<T> {
fn into_response(self) -> Response {
(
StatusCode::OK,
Json(json!({
"data": self.0.data,
"meta": self.0.meta,
"version": env!("CARGO_PKG_VERSION"),
})),
)
.into_response()
}
}
pub struct ApiMessage {
pub status: StatusCode,
pub message: String,
}
impl ApiMessage {
pub fn ok(message: impl Into<String>) -> Self {
Self { status: StatusCode::OK, message: message.into() }
}
pub fn created(message: impl Into<String>) -> Self {
Self { status: StatusCode::CREATED, message: message.into() }
}
pub fn new(status: StatusCode, message: impl Into<String>) -> Self {
Self { status, message: message.into() }
}
}
impl IntoResponse for ApiMessage {
fn into_response(self) -> Response {
(
self.status,
Json(json!({ "message": self.message, "version": env!("CARGO_PKG_VERSION") })),
)
.into_response()
}
}
-1
View File
@@ -1 +0,0 @@
pub mod permissions;
-28
View File
@@ -1,28 +0,0 @@
use axum::http::StatusCode;
use validator::Validate;
pub fn validate_request<T: Validate>(
payload: &T,
) -> Result<(), (StatusCode, String)> {
if let Err(validation_errors) = payload.validate() {
let error_messages: Vec<String> = validation_errors
.field_errors()
.iter()
.flat_map(|(_, errors)| {
errors.iter().map(move |error| {
format!(
"{}",
error
.message
.clone()
.unwrap_or_else(|| "Invalid value".into())
)
})
})
.collect();
return Err((StatusCode::BAD_REQUEST, error_messages.join(", ")));
}
Ok(())
}