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:
co-authored by
Claude Sonnet 4.6
parent
1b3366d735
commit
e432a1a743
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 +0,0 @@
|
||||
pub mod permissions;
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user