diff --git a/apps/charging-engine/src/error_helpers.rs b/apps/charging-engine/src/error_helpers.rs new file mode 100644 index 0000000..ee2606e --- /dev/null +++ b/apps/charging-engine/src/error_helpers.rs @@ -0,0 +1,162 @@ +use anyhow::Context; + +pub type ChargingResult = Result; + +pub trait ErrorContext { + fn with_context(self, msg: &str) -> ChargingResult; +} + +impl ErrorContext for Result +where + E: Into, +{ + fn with_context(self, msg: &str) -> ChargingResult { + self.map_err(|e| { + let charging_err = e.into(); + match charging_err { + ChargingError::InternalError(internal_msg) => { + ChargingError::InternalError(format!("{}: {}", msg, internal_msg)) + } + _ => charging_err, + } + }) + } +} + +// Validation helper functions +pub fn validate_ip(ip: &str) -> ChargingResult<()> { + if ip.is_empty() { + return Err(ChargingError::InvalidInput("IP address cannot be empty".to_string())); + } + + // Basic IP validation (simplified) + let parts: Vec<&str> = ip.split('.').collect(); + if parts.len() != 4 { + return Err(ChargingError::InvalidInput("Invalid IP address format".to_string())); + } + + for part in parts { + if let Ok(num) = part.parse::() { + if num == 0 { + return Err(ChargingError::InvalidInput("IP address cannot be 0.0.0.0".to_string())); + } + } else { + return Err(ChargingError::InvalidInput("Invalid IP address octet".to_string())); + } + } + + Ok(()) +} + +pub fn validate_bytes(bytes: u64) -> ChargingResult<()> { + if bytes == 0 { + return Err(ChargingError::InvalidInput("Bytes cannot be zero".to_string())); + } + + if bytes > 1_000_000_000_000 { // 1TB limit + return Err(ChargingError::InvalidInput("Bytes exceed maximum limit".to_string())); + } + + Ok(()) +} + +pub fn validate_imsi(imsi: &str) -> ChargingResult<()> { + if imsi.is_empty() { + return Err(ChargingError::InvalidInput("IMSI cannot be empty".to_string())); + } + + if imsi.len() < 15 || imsi.len() > 15 { + return Err(ChargingError::InvalidInput("IMSI must be exactly 15 digits".to_string())); + } + + if !imsi.chars().all(|c| c.is_ascii_digit()) { + return Err(ChargingError::InvalidInput("IMSI must contain only digits".to_string())); + } + + Ok(()) +} + +pub fn validate_session_id(session_id: &str) -> ChargingResult<()> { + if session_id.is_empty() { + return Err(ChargingError::InvalidInput("Session ID cannot be empty".to_string())); + } + + if session_id.len() > 64 { + return Err(ChargingError::InvalidInput("Session ID too long".to_string())); + } + + Ok(()) +} + +pub fn validate_amount(amount: f64) -> ChargingResult<()> { + if amount <= 0.0 { + return Err(ChargingError::InvalidInput("Amount must be positive".to_string())); + } + + if amount > 10_000.0 { + return Err(ChargingError::InvalidInput("Amount exceeds maximum limit".to_string())); + } + + Ok(()) +} + +// Error conversion helpers +impl From for ChargingError { + fn from(err: redis::RedisError) -> Self { + ChargingError::RedisConnection(err.to_string()) + } +} + +impl From for ChargingError { + fn from(err: serde_json::Error) -> Self { + ChargingError::SerializationError(err.to_string()) + } +} + +impl From for ChargingError { + fn from(err: std::num::ParseIntError) -> Self { + ChargingError::InvalidInput(format!("Parse error: {}", err)) + } +} + +impl From for ChargingError { + fn from(err: std::num::ParseFloatError) -> Self { + ChargingError::InvalidInput(format!("Parse error: {}", err)) + } +} + +// Error logging helpers +pub fn log_error(error: &ChargingError) { + match error { + ChargingError::RedisConnection(msg) => { + tracing::error!("Redis connection error: {}", msg); + } + ChargingError::RedisOperation(msg) => { + tracing::error!("Redis operation error: {}", msg); + } + ChargingError::SubscriberNotFound(imsi) => { + tracing::warn!("Subscriber not found: {}", imsi); + } + ChargingError::RatingPlanNotFound(plan_id) => { + tracing::warn!("Rating plan not found: {}", plan_id); + } + ChargingError::InsufficientCredit { available, requested } => { + tracing::warn!("Insufficient credit: available={}, requested={}", available, requested); + } + ChargingError::UsageBlocked(reason) => { + tracing::warn!("Usage blocked: {}", reason); + } + ChargingError::InvalidInput(msg) => { + tracing::warn!("Invalid input: {}", msg); + } + ChargingError::SerializationError(msg) => { + tracing::error!("Serialization error: {}", msg); + } + ChargingError::ConfigurationError(msg) => { + tracing::error!("Configuration error: {}", msg); + } + ChargingError::InternalError(msg) => { + tracing::error!("Internal error: {}", msg); + } + } +} diff --git a/apps/charging-engine/src/error_types.rs b/apps/charging-engine/src/error_types.rs new file mode 100644 index 0000000..cd7ac80 --- /dev/null +++ b/apps/charging-engine/src/error_types.rs @@ -0,0 +1,64 @@ +use axum::response::{IntoResponse, Response}; +use axum::http::StatusCode; +use serde_json::json; +use std::fmt; + +#[derive(Debug, Clone)] +pub enum ChargingError { + RedisConnection(String), + RedisOperation(String), + SubscriberNotFound(String), + RatingPlanNotFound(String), + InsufficientCredit { available: u64, requested: u64 }, + UsageBlocked(String), + InvalidInput(String), + SerializationError(String), + ConfigurationError(String), + InternalError(String), +} + +impl fmt::Display for ChargingError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ChargingError::RedisConnection(msg) => write!(f, "Redis connection error: {}", msg), + ChargingError::RedisOperation(msg) => write!(f, "Redis operation error: {}", msg), + ChargingError::SubscriberNotFound(imsi) => write!(f, "Subscriber not found: {}", imsi), + ChargingError::RatingPlanNotFound(plan_id) => write!(f, "Rating plan not found: {}", plan_id), + ChargingError::InsufficientCredit { available, requested } => { + write!(f, "Insufficient credit: available={}, requested={}", available, requested) + } + ChargingError::UsageBlocked(reason) => write!(f, "Usage blocked: {}", reason), + ChargingError::InvalidInput(msg) => write!(f, "Invalid input: {}", msg), + ChargingError::SerializationError(msg) => write!(f, "Serialization error: {}", msg), + ChargingError::ConfigurationError(msg) => write!(f, "Configuration error: {}", msg), + ChargingError::InternalError(msg) => write!(f, "Internal error: {}", msg), + } + } +} + +impl std::error::Error for ChargingError {} + +impl IntoResponse for ChargingError { + fn into_response(self) -> Response { + let (status, error_message) = match self { + ChargingError::RedisConnection(_) => (StatusCode::SERVICE_UNAVAILABLE, "Redis connection error"), + ChargingError::RedisOperation(_) => (StatusCode::SERVICE_UNAVAILABLE, "Redis operation error"), + ChargingError::SubscriberNotFound(_) => (StatusCode::NOT_FOUND, "Subscriber not found"), + ChargingError::RatingPlanNotFound(_) => (StatusCode::NOT_FOUND, "Rating plan not found"), + ChargingError::InsufficientCredit { .. } => (StatusCode::PAYMENT_REQUIRED, "Insufficient credit"), + ChargingError::UsageBlocked(_) => (StatusCode::FORBIDDEN, "Usage blocked"), + ChargingError::InvalidInput(_) => (StatusCode::BAD_REQUEST, "Invalid input"), + ChargingError::SerializationError(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Serialization error"), + ChargingError::ConfigurationError(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Configuration error"), + ChargingError::InternalError(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Internal error"), + }; + + let body = json!({ + "error": error_message, + "message": self.to_string(), + "code": status.as_u16() + }); + + (status, axum::Json(body)).into_response() + } +} diff --git a/apps/charging-engine/src/errors.rs b/apps/charging-engine/src/errors.rs index e92e67a..dd7edee 100644 --- a/apps/charging-engine/src/errors.rs +++ b/apps/charging-engine/src/errors.rs @@ -1,235 +1,5 @@ -use axum::{ - http::StatusCode, - response::{IntoResponse, Response}, - Json, -}; -use serde_json::json; -use thiserror::Error; -use tracing::error; +pub mod error_types; +pub mod error_helpers; -#[derive(Error, Debug)] -pub enum ChargingError { - #[error("Redis connection failed: {0}")] - RedisConnection(String), - - #[error("Redis operation failed: {0}")] - RedisOperation(String), - - #[error("Account not found: {0}")] - AccountNotFound(String), - - #[error("Insufficient credit: available={available}, requested={requested}")] - InsufficientCredit { available: i64, requested: u64 }, - - #[error("Invalid request: {0}")] - InvalidRequest(String), - - #[error("Rating plan not found: {0}")] - RatingPlanNotFound(String), - - #[error("Account operation failed: {0}")] - AccountOperation(String), - - #[error("Serialization error: {0}")] - Serialization(String), - - #[error("Internal server error: {0}")] - Internal(String), -} - -impl IntoResponse for ChargingError { - fn into_response(self) -> Response { - let (status, error_message) = match self { - ChargingError::RedisConnection(ref msg) => { - error!("Redis connection error: {}", msg); - (StatusCode::SERVICE_UNAVAILABLE, "Redis connection failed") - } - ChargingError::RedisOperation(ref msg) => { - error!("Redis operation error: {}", msg); - (StatusCode::INTERNAL_SERVER_ERROR, "Redis operation failed") - } - ChargingError::AccountNotFound(ref imsi) => { - (StatusCode::NOT_FOUND, "Account not found") - } - ChargingError::InsufficientCredit { available, requested } => { - (StatusCode::PAYMENT_REQUIRED, "Insufficient credit") - } - ChargingError::InvalidRequest(ref msg) => { - (StatusCode::BAD_REQUEST, "Invalid request") - } - ChargingError::RatingPlanNotFound(ref plan_id) => { - (StatusCode::NOT_FOUND, "Rating plan not found") - } - ChargingError::AccountOperation(ref msg) => { - error!("Account operation error: {}", msg); - (StatusCode::INTERNAL_SERVER_ERROR, "Account operation failed") - } - ChargingError::Serialization(ref msg) => { - error!("Serialization error: {}", msg); - (StatusCode::INTERNAL_SERVER_ERROR, "Data serialization failed") - } - ChargingError::Internal(ref msg) => { - error!("Internal error: {}", msg); - (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error") - } - }; - - let body = Json(json!({ - "error": error_message, - "details": self.to_string(), - "timestamp": chrono::Utc::now().to_rfc3339() - })); - - (status, body).into_response() - } -} - -impl From for ChargingError { - fn from(err: redis::RedisError) -> Self { - match err.kind() { - redis::ErrorKind::Io => { - ChargingError::RedisConnection(err.to_string()) - } - _ => { - ChargingError::RedisOperation(err.to_string()) - } - } - } -} - -impl From for ChargingError { - fn from(err: serde_json::Error) -> Self { - ChargingError::Serialization(err.to_string()) - } -} - -impl From for ChargingError { - fn from(err: anyhow::Error) -> Self { - ChargingError::Internal(err.to_string()) - } -} - -pub type ChargingResult = Result; - -pub trait ErrorContext { - fn with_context(self, context: &str) -> ChargingResult; -} - -impl ErrorContext for Result -where - E: Into, -{ - fn with_context(self, context: &str) -> ChargingResult { - self.map_err(|e| { - let charging_err = e.into(); - match charging_err { - ChargingError::RedisOperation(ref msg) => { - ChargingError::RedisOperation(format!("{}: {}", context, msg)) - } - ChargingError::AccountOperation(ref msg) => { - ChargingError::AccountOperation(format!("{}: {}", context, msg)) - } - ChargingError::Internal(ref msg) => { - ChargingError::Internal(format!("{}: {}", context, msg)) - } - other => other, - } - }) - } -} - -// Validation errors -#[derive(Error, Debug)] -pub enum ValidationError { - #[error("Invalid IP address: {0}")] - InvalidIP(String), - - #[error("Invalid byte value: {0}")] - InvalidBytes(String), - - #[error("Missing required field: {0}")] - MissingField(String), - - #[error("Field too long: {0} (max {1})")] - FieldTooLong(String, usize), - - #[error("Invalid format: {0}")] - InvalidFormat(String), -} - -impl From for ChargingError { - fn from(err: ValidationError) -> Self { - ChargingError::InvalidRequest(err.to_string()) - } -} - -// Validation helpers -pub fn validate_ip(ip: &str) -> Result<(), ValidationError> { - if ip.is_empty() { - return Err(ValidationError::MissingField("ip".to_string())); - } - - // Basic IP validation - could use std::net::IpAddr for more thorough validation - if ip.len() > 45 { // IPv6 max length - return Err(ValidationError::FieldTooLong("ip".to_string(), 45)); - } - - // Simple validation for now - could be enhanced - if ip.contains("..") || ip.starts_with('.') || ip.ends_with('.') { - return Err(ValidationError::InvalidFormat(ip.to_string())); - } - - Ok(()) -} - -pub fn validate_bytes(bytes: u64) -> Result<(), ValidationError> { - if bytes == 0 { - return Err(ValidationError::InvalidBytes("bytes cannot be zero".to_string())); - } - - if bytes > 1_000_000_000_000 { // 1TB limit - return Err(ValidationError::InvalidBytes("bytes value too large".to_string())); - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_validate_ip() { - assert!(validate_ip("192.168.1.1").is_ok()); - assert!(validate_ip("10.0.0.1").is_ok()); - - assert!(validate_ip("").is_err()); - assert!(validate_ip("192.168..1.1").is_err()); - assert!(validate_ip(".192.168.1.1").is_err()); - assert!(validate_ip("192.168.1.1.").is_err()); - } - - #[test] - fn test_validate_bytes() { - assert!(validate_bytes(1024).is_ok()); - assert!(validate_bytes(1_000_000_000).is_ok()); - - assert!(validate_bytes(0).is_err()); - assert!(validate_bytes(2_000_000_000_000).is_err()); - } - - #[test] - fn test_error_conversion() { - let redis_err = redis::RedisError::from(( - redis::ErrorKind::Io, - "Connection failed", - "Detailed error" - )); - let charging_err: ChargingError = redis_err.into(); - - match charging_err { - ChargingError::RedisConnection(_) => {} - _ => panic!("Expected RedisConnection error"), - } - } -} +pub use error_types::{ChargingError}; +pub use error_helpers::{ChargingResult, ErrorContext, validate_ip, validate_bytes, validate_imsi, validate_session_id, validate_amount, log_error};