adding initial veza-common

This commit is contained in:
okinrev 2025-12-03 22:24:14 +01:00
parent a01d7a25ac
commit 5ecd02b1db
17 changed files with 4087 additions and 0 deletions

33
veza-common/Cargo.toml Normal file
View file

@ -0,0 +1,33 @@
[package]
name = "veza-common"
version = "0.1.0"
edition = "2021"
authors = ["Veza Team <team@veza.live>"]
description = "Common library for Veza project - shared types and utilities"
license = "MIT"
repository = "https://github.com/okinrev/veza-full-stack"
[dependencies]
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# UUID support
uuid = { version = "1.0", features = ["v4", "serde"] }
# Date/time handling
chrono = { version = "0.4", features = ["serde"] }
# Error handling
thiserror = "1.0"
# Regex support
regex = "1.0"
# Lazy static for compiled regex
lazy_static = "1.4"
[dev-dependencies]
# Testing
tokio = { version = "1.0", features = ["full"] }

372
veza-common/README.md Normal file
View file

@ -0,0 +1,372 @@
# Veza Common Library
Bibliothèque commune pour tous les services Veza. Cette bibliothèque fournit des types partagés, des utilitaires et des configurations communes utilisées par tous les services du projet Veza.
## Installation
Ajoutez cette dépendance à votre `Cargo.toml`:
```toml
[dependencies]
veza-common = { path = "../veza-common" }
```
## Modules
### Types (`types`)
Types de données partagés pour tous les services.
#### User
```rust
use veza_common::types::User;
let user = User::new(1, "john_doe".to_string(), "john@example.com".to_string());
assert!(user.validate().is_ok());
```
#### Track
```rust
use veza_common::types::Track;
use uuid::Uuid;
let track_id = Uuid::new_v4();
let track = Track::new(
track_id,
"Song Title".to_string(),
"Artist Name".to_string(),
180 // duration in seconds
);
assert_eq!(track.formatted_duration(), "3:00");
```
#### Playlist
```rust
use veza_common::types::Playlist;
use uuid::Uuid;
let playlist_id = Uuid::new_v4();
let owner_id = Uuid::new_v4();
let mut playlist = Playlist::new(playlist_id, "My Playlist".to_string(), owner_id);
let track_id = Uuid::new_v4();
playlist.add_track(track_id);
assert_eq!(playlist.track_count(), 1);
```
#### API Response
```rust
use veza_common::types::ApiResponse;
// Success response
let response = ApiResponse::success("data".to_string());
assert!(response.success);
// Error response
let error_response = ApiResponse::<String>::error("Error message".to_string());
assert!(!error_response.success);
```
### Error Handling (`error`)
Types d'erreurs standardisés avec codes HTTP et messages.
```rust
use veza_common::error::{CommonError, CommonResult};
fn example_function() -> CommonResult<i32> {
// Return success
Ok(42)
// Or error
// Err(CommonError::NotFound("Resource not found".to_string()))
}
// Error codes
let error = CommonError::ValidationError("Invalid input".to_string());
assert_eq!(error.code(), "VALIDATION_ERROR");
assert_eq!(error.http_status_code(), 400);
```
### Configuration (`config`)
Types de configuration pour Database et Redis.
#### DatabaseConfig
```rust
use veza_common::config::DatabaseConfig;
use std::time::Duration;
let config = DatabaseConfig::new(
"postgresql://user:password@localhost:5432/mydb".to_string(),
10 // max_connections
);
// Validate configuration
assert!(config.validate().is_ok());
// Extract information
let host = config.host(); // Some("localhost")
let port = config.port(); // Some(5432)
let db_name = config.database_name(); // Some("mydb")
```
#### RedisConfig
```rust
use veza_common::config::RedisConfig;
let config = RedisConfig::new("redis://localhost:6379".to_string());
// Validate configuration
assert!(config.validate().is_ok());
// Check SSL
let is_ssl = config.is_ssl(); // false for redis://, true for rediss://
// Extract information
let host = config.host(); // Some("localhost")
let port = config.port(); // Some(6379)
```
### Utilities (`utils`)
#### Validation
```rust
use veza_common::utils::validation::*;
// Email validation
assert!(validate_email("test@example.com"));
assert!(!validate_email("invalid-email"));
// Username validation
assert!(validate_username("user123"));
assert!(!validate_username("ab")); // Too short
// Password validation
assert!(validate_password("Password123"));
assert!(!validate_password("short")); // Too short
// With Result return
let result = validate_email_result("test@example.com");
assert!(result.is_ok());
```
#### Serialization
```rust
use veza_common::utils::serialization::*;
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Debug, PartialEq)]
struct MyStruct {
name: String,
value: i32,
}
let data = MyStruct {
name: "test".to_string(),
value: 42,
};
// Serialize to JSON
let json = to_json(&data).unwrap();
// Deserialize from JSON
let deserialized: MyStruct = from_json(&json).unwrap();
assert_eq!(data, deserialized);
// Pretty print
let pretty = to_json_pretty(&data).unwrap();
```
#### Date Utilities
```rust
use veza_common::utils::date::*;
use chrono::{DateTime, Utc, Duration};
// Format timestamp
let timestamp = 1609459200; // 2021-01-01 00:00:00 UTC
let formatted = format_timestamp(timestamp);
// Parse date
let date_str = "2021-01-01T00:00:00Z";
let dt: DateTime<Utc> = parse_date(date_str).unwrap();
// Relative time
let now = Utc::now();
let past = now - Duration::hours(2);
let relative = format_relative_time(&past, &now); // "2 hours ago"
// Current timestamp
let ts = current_timestamp();
```
#### Logging
```rust
use veza_common::utils::logging::*;
use std::collections::HashMap;
// Simple logging
log_info("api", "Service started");
log_error("api", "Connection failed", None);
// Request logging
log_request("api", "GET", "/users");
// Request with context
let mut context = HashMap::new();
context.insert("user_id".to_string(), "123".to_string());
log_request_with_context("api", "POST", "/users", &context);
// Structured logging
let entry = StructuredLogEntry::new("api", "INFO", "User created")
.with_context("user_id".to_string(), "123".to_string());
let json = entry.to_json();
```
#### General Utilities
```rust
use veza_common::utils::*;
// Generate UUID
let uuid = generate_uuid();
// Format duration
let formatted = format_duration(125); // "2:05"
// Format file size
let size = format_file_size(1024); // "1.00 KB"
```
## Exemples d'Usage Complets
### Exemple 1: Créer et valider un utilisateur
```rust
use veza_common::types::User;
use veza_common::utils::validation::validate_email_result;
fn create_user(username: String, email: String) -> Result<User, String> {
// Validate email
validate_email_result(&email)?;
// Create user
let user = User::new(1, username, email);
// Validate user data
user.validate()?;
Ok(user)
}
```
### Exemple 2: Configuration de base de données
```rust
use veza_common::config::DatabaseConfig;
use veza_common::error::CommonResult;
fn setup_database() -> CommonResult<DatabaseConfig> {
let config = DatabaseConfig::new(
std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgresql://localhost/mydb".to_string()),
20
);
// Validate configuration
config.validate()?;
Ok(config)
}
```
### Exemple 3: API Response avec pagination
```rust
use veza_common::types::{ApiResponse, PaginatedResponse};
fn get_users(page: u32, limit: u32) -> ApiResponse<PaginatedResponse<User>> {
let items = vec![/* ... */];
let total = 100;
let paginated = PaginatedResponse::new(items, total, page, limit);
ApiResponse::success(paginated)
}
```
### Exemple 4: Gestion d'erreurs
```rust
use veza_common::error::{CommonError, CommonResult};
fn process_request(data: &str) -> CommonResult<String> {
if data.is_empty() {
return Err(CommonError::ValidationError(
"Data cannot be empty".to_string()
));
}
// Process data...
Ok("processed".to_string())
}
```
## Tests
La bibliothèque inclut une infrastructure de tests complète dans `tests/common_tests.rs` avec:
- **Fixtures**: Fonctions pour créer des données de test
- **Helpers**: Fonctions utilitaires pour les tests
- **Exemples**: Tests d'intégration complets
Exécuter les tests:
```bash
cargo test
```
## Documentation API
Pour générer la documentation complète:
```bash
cargo doc --open
```
## Structure du Projet
```
veza-common/
├── src/
│ ├── lib.rs # Module principal
│ ├── types/ # Types partagés (User, Track, Playlist)
│ ├── error.rs # Gestion d'erreurs
│ ├── config/ # Configurations (Database, Redis)
│ └── utils/ # Utilitaires
│ ├── validation.rs
│ ├── serialization.rs
│ ├── date.rs
│ └── logging.rs
├── tests/
│ └── common_tests.rs # Tests d'intégration
└── Cargo.toml
```
## Licence
MIT
## Contribution
Les contributions sont les bienvenues ! Veuillez suivre les standards de code définis dans `ORIGIN_CODE_STANDARDS.md`.

View file

@ -0,0 +1,306 @@
//! Database configuration types
//!
//! This module defines configuration structures for database connections.
use serde::{Deserialize, Serialize};
use std::time::Duration;
use crate::error::{CommonError, CommonResult};
/// Database configuration
///
/// Configuration for database connections including connection pooling settings.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DatabaseConfig {
/// Database connection URL
pub url: String,
/// Maximum number of connections in the pool
pub max_connections: u32,
/// Minimum number of connections in the pool
#[serde(default = "default_min_connections")]
pub min_connections: u32,
/// Connection timeout
#[serde(with = "duration_secs", default = "default_connection_timeout")]
pub connection_timeout: Duration,
/// Idle timeout before closing connections
#[serde(with = "duration_secs", default = "default_idle_timeout")]
pub idle_timeout: Duration,
/// Maximum lifetime of a connection
#[serde(with = "duration_secs", default = "default_max_lifetime")]
pub max_lifetime: Duration,
/// Enable SQL query logging
#[serde(default = "default_false")]
pub enable_logging: bool,
/// Run migrations on startup
#[serde(default = "default_false")]
pub migrate_on_start: bool,
}
fn default_min_connections() -> u32 {
1
}
fn default_connection_timeout() -> Duration {
Duration::from_secs(30)
}
fn default_idle_timeout() -> Duration {
Duration::from_secs(600)
}
fn default_max_lifetime() -> Duration {
Duration::from_secs(3600)
}
fn default_false() -> bool {
false
}
/// Serialize/Deserialize Duration as seconds
mod duration_secs {
use serde::{Deserialize, Deserializer, Serializer};
use std::time::Duration;
pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_u64(duration.as_secs())
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
where
D: Deserializer<'de>,
{
let secs = u64::deserialize(deserializer)?;
Ok(Duration::from_secs(secs))
}
}
impl Default for DatabaseConfig {
fn default() -> Self {
Self {
url: "postgresql://user:password@localhost/database".to_string(),
max_connections: 10,
min_connections: 1,
connection_timeout: Duration::from_secs(30),
idle_timeout: Duration::from_secs(600),
max_lifetime: Duration::from_secs(3600),
enable_logging: false,
migrate_on_start: false,
}
}
}
impl DatabaseConfig {
/// Create a new database configuration
pub fn new(url: String, max_connections: u32) -> Self {
Self {
url,
max_connections,
min_connections: 1,
connection_timeout: Duration::from_secs(30),
idle_timeout: Duration::from_secs(600),
max_lifetime: Duration::from_secs(3600),
enable_logging: false,
migrate_on_start: false,
}
}
/// Validate the database configuration
pub fn validate(&self) -> CommonResult<()> {
if self.url.is_empty() {
return Err(CommonError::ValidationError(
"Database URL cannot be empty".to_string()
));
}
if !self.url.starts_with("postgresql://") && !self.url.starts_with("postgres://") {
return Err(CommonError::ValidationError(
"Database URL must start with postgresql:// or postgres://".to_string()
));
}
if self.max_connections == 0 {
return Err(CommonError::ValidationError(
"Max connections must be greater than 0".to_string()
));
}
if self.min_connections > self.max_connections {
return Err(CommonError::ValidationError(
"Min connections cannot be greater than max connections".to_string()
));
}
if self.connection_timeout.as_secs() == 0 {
return Err(CommonError::ValidationError(
"Connection timeout must be greater than 0".to_string()
));
}
Ok(())
}
/// Get the database name from the URL
pub fn database_name(&self) -> Option<String> {
self.url
.split('/')
.last()
.and_then(|s| s.split('?').next())
.map(|s| s.to_string())
}
/// Get the host from the URL
pub fn host(&self) -> Option<String> {
self.url
.strip_prefix("postgresql://")
.or_else(|| self.url.strip_prefix("postgres://"))
.and_then(|s| {
// Remove user:password@ if present
let s = if s.contains('@') {
s.split('@').nth(1).unwrap_or(s)
} else {
s
};
// Extract host:port
s.split('/').next().map(|s| {
if s.contains(':') {
s.split(':').next().unwrap_or(s).to_string()
} else {
s.to_string()
}
})
})
}
/// Get the port from the URL
pub fn port(&self) -> Option<u16> {
self.url
.strip_prefix("postgresql://")
.or_else(|| self.url.strip_prefix("postgres://"))
.and_then(|s| {
let s = if s.contains('@') {
s.split('@').nth(1).unwrap_or(s)
} else {
s
};
s.split('/').next().and_then(|s| {
if s.contains(':') {
s.split(':').nth(1).and_then(|p| p.parse().ok())
} else {
None
}
})
})
.or(Some(5432)) // Default PostgreSQL port
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_database_config_default() {
let config = DatabaseConfig::default();
assert!(!config.url.is_empty());
assert_eq!(config.max_connections, 10);
assert_eq!(config.min_connections, 1);
}
#[test]
fn test_database_config_new() {
let config = DatabaseConfig::new("postgresql://localhost/test".to_string(), 20);
assert_eq!(config.url, "postgresql://localhost/test");
assert_eq!(config.max_connections, 20);
assert_eq!(config.min_connections, 1);
}
#[test]
fn test_database_config_validate_success() {
let config = DatabaseConfig::new("postgresql://user:pass@localhost/db".to_string(), 10);
assert!(config.validate().is_ok());
}
#[test]
fn test_database_config_validate_empty_url() {
let mut config = DatabaseConfig::default();
config.url = "".to_string();
assert!(config.validate().is_err());
}
#[test]
fn test_database_config_validate_invalid_url() {
let config = DatabaseConfig::new("mysql://localhost/db".to_string(), 10);
assert!(config.validate().is_err());
}
#[test]
fn test_database_config_validate_zero_max_connections() {
let config = DatabaseConfig::new("postgresql://localhost/db".to_string(), 0);
assert!(config.validate().is_err());
}
#[test]
fn test_database_config_validate_min_greater_than_max() {
let mut config = DatabaseConfig::new("postgresql://localhost/db".to_string(), 10);
config.min_connections = 20;
assert!(config.validate().is_err());
}
#[test]
fn test_database_config_database_name() {
let config = DatabaseConfig::new("postgresql://localhost/mydb".to_string(), 10);
assert_eq!(config.database_name(), Some("mydb".to_string()));
}
#[test]
fn test_database_config_database_name_with_params() {
let config = DatabaseConfig::new("postgresql://localhost/mydb?sslmode=require".to_string(), 10);
assert_eq!(config.database_name(), Some("mydb".to_string()));
}
#[test]
fn test_database_config_host() {
let config = DatabaseConfig::new("postgresql://localhost/mydb".to_string(), 10);
assert_eq!(config.host(), Some("localhost".to_string()));
}
#[test]
fn test_database_config_host_with_auth() {
let config = DatabaseConfig::new("postgresql://user:pass@localhost/mydb".to_string(), 10);
assert_eq!(config.host(), Some("localhost".to_string()));
}
#[test]
fn test_database_config_port() {
let config = DatabaseConfig::new("postgresql://localhost:5433/mydb".to_string(), 10);
assert_eq!(config.port(), Some(5433));
}
#[test]
fn test_database_config_port_default() {
let config = DatabaseConfig::new("postgresql://localhost/mydb".to_string(), 10);
assert_eq!(config.port(), Some(5432));
}
#[test]
fn test_database_config_serialize() {
let config = DatabaseConfig::default();
let json = serde_json::to_string(&config).unwrap();
assert!(json.contains("url"));
assert!(json.contains("max_connections"));
}
#[test]
fn test_database_config_deserialize() {
let json = r#"{
"url": "postgresql://localhost/test",
"max_connections": 20
}"#;
let config: DatabaseConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.url, "postgresql://localhost/test");
assert_eq!(config.max_connections, 20);
}
}

View file

@ -0,0 +1,10 @@
//! Configuration types module
//!
//! This module provides shared configuration types for Veza services.
pub mod database;
pub mod redis;
pub use database::DatabaseConfig;
pub use redis::RedisConfig;

View file

@ -0,0 +1,312 @@
//! Redis configuration types
//!
//! This module defines configuration structures for Redis connections.
use serde::{Deserialize, Serialize};
use std::time::Duration;
use crate::error::{CommonError, CommonResult};
/// Redis configuration
///
/// Configuration for Redis connections including connection pooling settings.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RedisConfig {
/// Redis connection URL
pub url: String,
/// Maximum number of connections in the pool
#[serde(default = "default_max_connections")]
pub max_connections: u32,
/// Connection timeout
#[serde(with = "duration_secs", default = "default_connection_timeout")]
pub connection_timeout: Duration,
/// Command timeout
#[serde(with = "duration_secs", default = "default_command_timeout")]
pub command_timeout: Duration,
/// Enable connection keepalive
#[serde(default = "default_true")]
pub keepalive: bool,
/// Database number (0-15)
#[serde(default = "default_db")]
pub db: u8,
/// Password for authentication (optional)
#[serde(skip_serializing_if = "Option::is_none")]
pub password: Option<String>,
/// Username for authentication (optional)
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
}
fn default_max_connections() -> u32 {
10
}
fn default_connection_timeout() -> Duration {
Duration::from_secs(5)
}
fn default_command_timeout() -> Duration {
Duration::from_secs(3)
}
fn default_true() -> bool {
true
}
fn default_db() -> u8 {
0
}
/// Serialize/Deserialize Duration as seconds
mod duration_secs {
use serde::{Deserialize, Deserializer, Serializer};
use std::time::Duration;
pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_u64(duration.as_secs())
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
where
D: Deserializer<'de>,
{
let secs = u64::deserialize(deserializer)?;
Ok(Duration::from_secs(secs))
}
}
impl Default for RedisConfig {
fn default() -> Self {
Self {
url: "redis://localhost:6379".to_string(),
max_connections: 10,
connection_timeout: Duration::from_secs(5),
command_timeout: Duration::from_secs(3),
keepalive: true,
db: 0,
password: None,
username: None,
}
}
}
impl RedisConfig {
/// Create a new Redis configuration
pub fn new(url: String) -> Self {
Self {
url,
max_connections: 10,
connection_timeout: Duration::from_secs(5),
command_timeout: Duration::from_secs(3),
keepalive: true,
db: 0,
password: None,
username: None,
}
}
/// Validate the Redis configuration
pub fn validate(&self) -> CommonResult<()> {
if self.url.is_empty() {
return Err(CommonError::ValidationError(
"Redis URL cannot be empty".to_string()
));
}
if !self.url.starts_with("redis://") && !self.url.starts_with("rediss://") {
return Err(CommonError::ValidationError(
"Redis URL must start with redis:// or rediss://".to_string()
));
}
if self.max_connections == 0 {
return Err(CommonError::ValidationError(
"Max connections must be greater than 0".to_string()
));
}
if self.db > 15 {
return Err(CommonError::ValidationError(
"Database number must be between 0 and 15".to_string()
));
}
if self.connection_timeout.as_secs() == 0 {
return Err(CommonError::ValidationError(
"Connection timeout must be greater than 0".to_string()
));
}
Ok(())
}
/// Get the host from the URL
pub fn host(&self) -> Option<String> {
self.url
.strip_prefix("redis://")
.or_else(|| self.url.strip_prefix("rediss://"))
.and_then(|s| {
// Remove user:password@ if present
let s = if s.contains('@') {
s.split('@').nth(1).unwrap_or(s)
} else {
s
};
// Extract host:port
s.split('/').next().map(|s| {
if s.contains(':') {
s.split(':').next().unwrap_or(s).to_string()
} else {
s.to_string()
}
})
})
}
/// Get the port from the URL
pub fn port(&self) -> Option<u16> {
self.url
.strip_prefix("redis://")
.or_else(|| self.url.strip_prefix("rediss://"))
.and_then(|s| {
let s = if s.contains('@') {
s.split('@').nth(1).unwrap_or(s)
} else {
s
};
s.split('/').next().and_then(|s| {
if s.contains(':') {
s.split(':').nth(1).and_then(|p| p.parse().ok())
} else {
None
}
})
})
.or(Some(6379)) // Default Redis port
}
/// Check if SSL/TLS is enabled
pub fn is_ssl(&self) -> bool {
self.url.starts_with("rediss://")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_redis_config_default() {
let config = RedisConfig::default();
assert!(!config.url.is_empty());
assert_eq!(config.max_connections, 10);
assert_eq!(config.db, 0);
assert!(config.keepalive);
}
#[test]
fn test_redis_config_new() {
let config = RedisConfig::new("redis://localhost:6379".to_string());
assert_eq!(config.url, "redis://localhost:6379");
assert_eq!(config.max_connections, 10);
assert_eq!(config.db, 0);
}
#[test]
fn test_redis_config_validate_success() {
let config = RedisConfig::new("redis://localhost:6379".to_string());
assert!(config.validate().is_ok());
}
#[test]
fn test_redis_config_validate_empty_url() {
let mut config = RedisConfig::default();
config.url = "".to_string();
assert!(config.validate().is_err());
}
#[test]
fn test_redis_config_validate_invalid_url() {
let config = RedisConfig::new("http://localhost:6379".to_string());
assert!(config.validate().is_err());
}
#[test]
fn test_redis_config_validate_zero_max_connections() {
let mut config = RedisConfig::new("redis://localhost:6379".to_string());
config.max_connections = 0;
assert!(config.validate().is_err());
}
#[test]
fn test_redis_config_validate_db_too_large() {
let mut config = RedisConfig::new("redis://localhost:6379".to_string());
config.db = 16;
assert!(config.validate().is_err());
}
#[test]
fn test_redis_config_host() {
let config = RedisConfig::new("redis://localhost:6379".to_string());
assert_eq!(config.host(), Some("localhost".to_string()));
}
#[test]
fn test_redis_config_host_with_auth() {
let config = RedisConfig::new("redis://user:pass@localhost:6379".to_string());
assert_eq!(config.host(), Some("localhost".to_string()));
}
#[test]
fn test_redis_config_port() {
let config = RedisConfig::new("redis://localhost:6380".to_string());
assert_eq!(config.port(), Some(6380));
}
#[test]
fn test_redis_config_port_default() {
let config = RedisConfig::new("redis://localhost".to_string());
assert_eq!(config.port(), Some(6379));
}
#[test]
fn test_redis_config_is_ssl() {
let config_ssl = RedisConfig::new("rediss://localhost:6379".to_string());
assert!(config_ssl.is_ssl());
let config_no_ssl = RedisConfig::new("redis://localhost:6379".to_string());
assert!(!config_no_ssl.is_ssl());
}
#[test]
fn test_redis_config_serialize() {
let config = RedisConfig::default();
let json = serde_json::to_string(&config).unwrap();
assert!(json.contains("url"));
assert!(json.contains("max_connections"));
assert!(!json.contains("password")); // Should be skipped if None
}
#[test]
fn test_redis_config_deserialize() {
let json = r#"{
"url": "redis://localhost:6379",
"max_connections": 20,
"db": 1
}"#;
let config: RedisConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.url, "redis://localhost:6379");
assert_eq!(config.max_connections, 20);
assert_eq!(config.db, 1);
}
#[test]
fn test_redis_config_with_password() {
let mut config = RedisConfig::new("redis://localhost:6379".to_string());
config.password = Some("secret".to_string());
assert_eq!(config.password, Some("secret".to_string()));
}
}

275
veza-common/src/error.rs Normal file
View file

@ -0,0 +1,275 @@
//! Common error types for Veza services
//!
//! This module provides standardized error handling across all Veza services.
use serde::{Deserialize, Serialize};
use thiserror::Error;
/// Common error type for Veza services
#[derive(Debug, Error, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", content = "message")]
pub enum CommonError {
/// Resource not found
#[error("Not found: {0}")]
NotFound(String),
/// Validation error
#[error("Validation error: {0}")]
ValidationError(String),
/// Internal server error
#[error("Internal error: {0}")]
InternalError(String),
/// Authentication error
#[error("Authentication error: {0}")]
AuthenticationError(String),
/// Authorization error
#[error("Authorization error: {0}")]
AuthorizationError(String),
/// Invalid input data
#[error("Invalid input: {0}")]
InvalidInput(String),
/// Conflict error (e.g., duplicate resource)
#[error("Conflict: {0}")]
Conflict(String),
/// Rate limit exceeded
#[error("Rate limit exceeded: {0}")]
RateLimitExceeded(String),
/// IO error
#[error("IO error: {0}")]
IoError(String),
/// Serialization/deserialization error
#[error("Serialization error: {0}")]
SerializationError(String),
}
/// Error code for each error type
impl CommonError {
/// Get the error code for this error
pub fn code(&self) -> &'static str {
match self {
CommonError::NotFound(_) => "NOT_FOUND",
CommonError::ValidationError(_) => "VALIDATION_ERROR",
CommonError::InternalError(_) => "INTERNAL_ERROR",
CommonError::AuthenticationError(_) => "AUTHENTICATION_ERROR",
CommonError::AuthorizationError(_) => "AUTHORIZATION_ERROR",
CommonError::InvalidInput(_) => "INVALID_INPUT",
CommonError::Conflict(_) => "CONFLICT",
CommonError::RateLimitExceeded(_) => "RATE_LIMIT_EXCEEDED",
CommonError::IoError(_) => "IO_ERROR",
CommonError::SerializationError(_) => "SERIALIZATION_ERROR",
}
}
/// Get the HTTP status code for this error
pub fn http_status_code(&self) -> u16 {
match self {
CommonError::NotFound(_) => 404,
CommonError::ValidationError(_) => 400,
CommonError::InternalError(_) => 500,
CommonError::AuthenticationError(_) => 401,
CommonError::AuthorizationError(_) => 403,
CommonError::InvalidInput(_) => 400,
CommonError::Conflict(_) => 409,
CommonError::RateLimitExceeded(_) => 429,
CommonError::IoError(_) => 500,
CommonError::SerializationError(_) => 400,
}
}
/// Get the error message
pub fn message(&self) -> &str {
match self {
CommonError::NotFound(msg) => msg,
CommonError::ValidationError(msg) => msg,
CommonError::InternalError(msg) => msg,
CommonError::AuthenticationError(msg) => msg,
CommonError::AuthorizationError(msg) => msg,
CommonError::InvalidInput(msg) => msg,
CommonError::Conflict(msg) => msg,
CommonError::RateLimitExceeded(msg) => msg,
CommonError::IoError(msg) => msg,
CommonError::SerializationError(msg) => msg,
}
}
/// Check if this error should be logged
pub fn should_log(&self) -> bool {
match self {
CommonError::InternalError(_) | CommonError::IoError(_) => true,
_ => false,
}
}
}
/// Result type alias for CommonError
pub type CommonResult<T> = Result<T, CommonError>;
/// Conversion from std::io::Error
impl From<std::io::Error> for CommonError {
fn from(err: std::io::Error) -> Self {
CommonError::IoError(err.to_string())
}
}
/// Conversion from serde_json::Error
impl From<serde_json::Error> for CommonError {
fn from(err: serde_json::Error) -> Self {
CommonError::SerializationError(err.to_string())
}
}
/// Conversion from uuid::Error
impl From<uuid::Error> for CommonError {
fn from(err: uuid::Error) -> Self {
CommonError::ValidationError(format!("Invalid UUID: {}", err))
}
}
/// Conversion from chrono::ParseError
impl From<chrono::ParseError> for CommonError {
fn from(err: chrono::ParseError) -> Self {
CommonError::ValidationError(format!("Invalid date format: {}", err))
}
}
/// JSON representation of the error for API responses
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErrorResponse {
pub error: String,
pub code: String,
pub status: u16,
pub message: String,
}
impl From<&CommonError> for ErrorResponse {
fn from(err: &CommonError) -> Self {
ErrorResponse {
error: err.code().to_string(),
code: err.code().to_string(),
status: err.http_status_code(),
message: err.message().to_string(),
}
}
}
impl From<CommonError> for ErrorResponse {
fn from(err: CommonError) -> Self {
ErrorResponse::from(&err)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_codes() {
assert_eq!(CommonError::NotFound("test".to_string()).code(), "NOT_FOUND");
assert_eq!(CommonError::ValidationError("test".to_string()).code(), "VALIDATION_ERROR");
assert_eq!(CommonError::InternalError("test".to_string()).code(), "INTERNAL_ERROR");
}
#[test]
fn test_http_status_codes() {
assert_eq!(CommonError::NotFound("test".to_string()).http_status_code(), 404);
assert_eq!(CommonError::ValidationError("test".to_string()).http_status_code(), 400);
assert_eq!(CommonError::InternalError("test".to_string()).http_status_code(), 500);
assert_eq!(CommonError::AuthenticationError("test".to_string()).http_status_code(), 401);
assert_eq!(CommonError::AuthorizationError("test".to_string()).http_status_code(), 403);
assert_eq!(CommonError::Conflict("test".to_string()).http_status_code(), 409);
assert_eq!(CommonError::RateLimitExceeded("test".to_string()).http_status_code(), 429);
}
#[test]
fn test_error_message() {
let err = CommonError::NotFound("Resource not found".to_string());
assert_eq!(err.message(), "Resource not found");
}
#[test]
fn test_error_should_log() {
assert!(CommonError::InternalError("test".to_string()).should_log());
assert!(CommonError::IoError("test".to_string()).should_log());
assert!(!CommonError::NotFound("test".to_string()).should_log());
assert!(!CommonError::ValidationError("test".to_string()).should_log());
}
#[test]
fn test_from_io_error() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "File not found");
let common_err: CommonError = io_err.into();
match common_err {
CommonError::IoError(msg) => assert!(msg.contains("File not found")),
_ => panic!("Expected IoError"),
}
}
#[test]
fn test_from_serde_json_error() {
let json_err = serde_json::from_str::<serde_json::Value>("invalid json").unwrap_err();
let common_err: CommonError = json_err.into();
match common_err {
CommonError::SerializationError(_) => {},
_ => panic!("Expected SerializationError"),
}
}
#[test]
fn test_error_response() {
let err = CommonError::NotFound("Resource not found".to_string());
let response: ErrorResponse = (&err).into();
assert_eq!(response.error, "NOT_FOUND");
assert_eq!(response.code, "NOT_FOUND");
assert_eq!(response.status, 404);
assert_eq!(response.message, "Resource not found");
}
#[test]
fn test_error_serialize() {
let err = CommonError::ValidationError("Invalid input".to_string());
let json = serde_json::to_string(&err).unwrap();
assert!(json.contains("ValidationError"));
assert!(json.contains("Invalid input"));
}
#[test]
fn test_error_deserialize() {
let json = r#"{"type":"ValidationError","message":"Invalid input"}"#;
let err: CommonError = serde_json::from_str(json).unwrap();
match err {
CommonError::ValidationError(msg) => assert_eq!(msg, "Invalid input"),
_ => panic!("Expected ValidationError"),
}
}
#[test]
fn test_display() {
let err = CommonError::NotFound("User not found".to_string());
let display = format!("{}", err);
assert!(display.contains("Not found"));
assert!(display.contains("User not found"));
}
#[test]
fn test_result_type() {
fn example_function() -> CommonResult<i32> {
Ok(42)
}
fn example_error() -> CommonResult<i32> {
Err(CommonError::NotFound("Not found".to_string()))
}
assert!(example_function().is_ok());
assert!(example_error().is_err());
}
}

14
veza-common/src/lib.rs Normal file
View file

@ -0,0 +1,14 @@
//! Veza Common Library
//!
//! This library provides common types and utilities shared across
//! all Veza services (backend, frontend, chat-server, stream-server).
pub mod types;
pub mod utils;
pub mod error;
pub mod config;
pub use types::*;
pub use error::{CommonError, CommonResult, ErrorResponse};
pub use config::*;

View file

@ -0,0 +1,154 @@
//! Common types module
//!
//! This module organizes shared types into sub-modules for better organization.
pub mod user;
pub mod track;
pub mod playlist;
// Re-export types for convenience
pub use user::User;
pub use track::Track;
pub use playlist::Playlist;
// Re-export type aliases and other common types
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use chrono::{DateTime, Utc};
/// User identifier
pub type UserId = Uuid;
/// Track identifier
pub type TrackId = Uuid;
/// Session identifier
pub type SessionId = Uuid;
/// Conversation identifier
pub type ConversationId = Uuid;
/// Message identifier
pub type MessageId = Uuid;
/// Session information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub id: SessionId,
pub user_id: UserId,
pub track_id: Option<TrackId>,
pub position: u64, // Position in seconds
pub is_playing: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// API response wrapper
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiResponse<T> {
pub success: bool,
pub data: Option<T>,
pub error: Option<String>,
pub timestamp: DateTime<Utc>,
}
impl<T> ApiResponse<T> {
/// Create a successful response
pub fn success(data: T) -> Self {
Self {
success: true,
data: Some(data),
error: None,
timestamp: Utc::now(),
}
}
/// Create an error response
pub fn error(message: String) -> Self {
Self {
success: false,
data: None,
error: Some(message),
timestamp: Utc::now(),
}
}
}
/// Pagination parameters
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PaginationParams {
pub page: u32,
pub limit: u32,
}
impl Default for PaginationParams {
fn default() -> Self {
Self {
page: 1,
limit: 20,
}
}
}
/// Paginated response
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PaginatedResponse<T> {
pub items: Vec<T>,
pub total: u64,
pub page: u32,
pub limit: u32,
pub total_pages: u32,
}
impl<T> PaginatedResponse<T> {
pub fn new(items: Vec<T>, total: u64, page: u32, limit: u32) -> Self {
let total_pages = (total as f64 / limit as f64).ceil() as u32;
Self {
items,
total,
page,
limit,
total_pages,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_api_response_success() {
let response = ApiResponse::success("test data");
assert!(response.success);
assert!(response.data.is_some());
assert!(response.error.is_none());
}
#[test]
fn test_api_response_error() {
let response = ApiResponse::<String>::error("test error".to_string());
assert!(!response.success);
assert!(response.data.is_none());
assert!(response.error.is_some());
}
#[test]
fn test_pagination_params_default() {
let params = PaginationParams::default();
assert_eq!(params.page, 1);
assert_eq!(params.limit, 20);
}
#[test]
fn test_paginated_response() {
let items = vec![1, 2, 3];
let response = PaginatedResponse::new(items.clone(), 100, 1, 20);
assert_eq!(response.items.len(), 3);
assert_eq!(response.total, 100);
assert_eq!(response.page, 1);
assert_eq!(response.limit, 20);
assert_eq!(response.total_pages, 5); // 100 / 20 = 5
}
}

View file

@ -0,0 +1,203 @@
//! Playlist type definition
//!
//! This module defines the Playlist type and related structures.
use serde::{Deserialize, Serialize};
use crate::types::{TrackId, UserId};
use chrono::{DateTime, Utc};
/// Playlist identifier
pub type PlaylistId = uuid::Uuid;
/// Playlist information
///
/// Represents a playlist in the Veza system.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Playlist {
/// Playlist ID
pub id: PlaylistId,
/// Playlist name
pub name: String,
/// Optional playlist description
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// User ID of the playlist owner
pub owner_id: UserId,
/// List of track IDs in the playlist
pub track_ids: Vec<TrackId>,
/// Whether the playlist is public
pub is_public: bool,
/// Playlist creation timestamp
#[serde(skip_serializing_if = "Option::is_none")]
pub created_at: Option<DateTime<Utc>>,
/// Playlist last update timestamp
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<DateTime<Utc>>,
}
impl Playlist {
/// Create a new Playlist instance
pub fn new(id: PlaylistId, name: String, owner_id: UserId) -> Self {
Self {
id,
name,
description: None,
owner_id,
track_ids: Vec::new(),
is_public: false,
created_at: None,
updated_at: None,
}
}
/// Add a track to the playlist
pub fn add_track(&mut self, track_id: TrackId) {
if !self.track_ids.contains(&track_id) {
self.track_ids.push(track_id);
}
}
/// Remove a track from the playlist
pub fn remove_track(&mut self, track_id: TrackId) {
self.track_ids.retain(|&id| id != track_id);
}
/// Get the number of tracks in the playlist
pub fn track_count(&self) -> usize {
self.track_ids.len()
}
/// Check if the playlist is empty
pub fn is_empty(&self) -> bool {
self.track_ids.is_empty()
}
/// Validate playlist data
pub fn validate(&self) -> Result<(), String> {
if self.name.is_empty() {
return Err("Playlist name cannot be empty".to_string());
}
if self.name.len() > 200 {
return Err("Playlist name cannot exceed 200 characters".to_string());
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use uuid::Uuid;
#[test]
fn test_playlist_new() {
let playlist_id = Uuid::new_v4();
let owner_id = Uuid::new_v4();
let playlist = Playlist::new(playlist_id, "My Playlist".to_string(), owner_id);
assert_eq!(playlist.id, playlist_id);
assert_eq!(playlist.name, "My Playlist");
assert_eq!(playlist.owner_id, owner_id);
assert!(playlist.track_ids.is_empty());
assert!(!playlist.is_public);
assert!(playlist.description.is_none());
}
#[test]
fn test_playlist_serialize() {
let playlist_id = Uuid::new_v4();
let owner_id = Uuid::new_v4();
let playlist = Playlist::new(playlist_id, "My Playlist".to_string(), owner_id);
let json = serde_json::to_string(&playlist).unwrap();
assert!(json.contains("\"name\":\"My Playlist\""));
assert!(json.contains("\"is_public\":false"));
assert!(json.contains("\"track_ids\":[]"));
}
#[test]
fn test_playlist_deserialize() {
let playlist_id = Uuid::new_v4();
let owner_id = Uuid::new_v4();
let json = format!(
r#"{{"id":"{}","name":"My Playlist","owner_id":"{}","track_ids":[],"is_public":false}}"#,
playlist_id, owner_id
);
let playlist: Playlist = serde_json::from_str(&json).unwrap();
assert_eq!(playlist.id, playlist_id);
assert_eq!(playlist.name, "My Playlist");
assert_eq!(playlist.owner_id, owner_id);
assert!(playlist.track_ids.is_empty());
}
#[test]
fn test_playlist_add_track() {
let playlist_id = Uuid::new_v4();
let owner_id = Uuid::new_v4();
let track_id = Uuid::new_v4();
let mut playlist = Playlist::new(playlist_id, "My Playlist".to_string(), owner_id);
playlist.add_track(track_id);
assert_eq!(playlist.track_count(), 1);
assert!(playlist.track_ids.contains(&track_id));
}
#[test]
fn test_playlist_add_duplicate_track() {
let playlist_id = Uuid::new_v4();
let owner_id = Uuid::new_v4();
let track_id = Uuid::new_v4();
let mut playlist = Playlist::new(playlist_id, "My Playlist".to_string(), owner_id);
playlist.add_track(track_id);
playlist.add_track(track_id);
assert_eq!(playlist.track_count(), 1);
}
#[test]
fn test_playlist_remove_track() {
let playlist_id = Uuid::new_v4();
let owner_id = Uuid::new_v4();
let track_id = Uuid::new_v4();
let mut playlist = Playlist::new(playlist_id, "My Playlist".to_string(), owner_id);
playlist.add_track(track_id);
assert_eq!(playlist.track_count(), 1);
playlist.remove_track(track_id);
assert_eq!(playlist.track_count(), 0);
assert!(playlist.is_empty());
}
#[test]
fn test_playlist_is_empty() {
let playlist_id = Uuid::new_v4();
let owner_id = Uuid::new_v4();
let playlist = Playlist::new(playlist_id, "My Playlist".to_string(), owner_id);
assert!(playlist.is_empty());
}
#[test]
fn test_playlist_validate_success() {
let playlist_id = Uuid::new_v4();
let owner_id = Uuid::new_v4();
let playlist = Playlist::new(playlist_id, "My Playlist".to_string(), owner_id);
assert!(playlist.validate().is_ok());
}
#[test]
fn test_playlist_validate_empty_name() {
let playlist_id = Uuid::new_v4();
let owner_id = Uuid::new_v4();
let playlist = Playlist::new(playlist_id, "".to_string(), owner_id);
assert!(playlist.validate().is_err());
}
#[test]
fn test_playlist_validate_name_too_long() {
let playlist_id = Uuid::new_v4();
let owner_id = Uuid::new_v4();
let long_name = "a".repeat(201);
let playlist = Playlist::new(playlist_id, long_name, owner_id);
assert!(playlist.validate().is_err());
}
}

View file

@ -0,0 +1,163 @@
//! Track type definition
//!
//! This module defines the Track type and related structures.
use serde::{Deserialize, Serialize};
use crate::types::TrackId;
use chrono::{DateTime, Utc};
/// Track information
///
/// Represents an audio track in the Veza system.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Track {
/// Track ID
pub id: TrackId,
/// Track title
pub title: String,
/// Track artist
pub artist: String,
/// Optional album name
#[serde(skip_serializing_if = "Option::is_none")]
pub album: Option<String>,
/// Track duration in seconds
pub duration: u64,
/// Optional file URL
#[serde(skip_serializing_if = "Option::is_none")]
pub file_url: Option<String>,
/// Optional cover image URL
#[serde(skip_serializing_if = "Option::is_none")]
pub cover_url: Option<String>,
/// Track creation timestamp
#[serde(skip_serializing_if = "Option::is_none")]
pub created_at: Option<DateTime<Utc>>,
/// Track last update timestamp
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<DateTime<Utc>>,
}
impl Track {
/// Create a new Track instance
pub fn new(id: TrackId, title: String, artist: String, duration: u64) -> Self {
Self {
id,
title,
artist,
album: None,
duration,
file_url: None,
cover_url: None,
created_at: None,
updated_at: None,
}
}
/// Validate track data
pub fn validate(&self) -> Result<(), String> {
if self.title.is_empty() {
return Err("Title cannot be empty".to_string());
}
if self.artist.is_empty() {
return Err("Artist cannot be empty".to_string());
}
if self.duration == 0 {
return Err("Duration must be greater than 0".to_string());
}
Ok(())
}
/// Format duration as MM:SS or HH:MM:SS
pub fn formatted_duration(&self) -> String {
let hours = self.duration / 3600;
let minutes = (self.duration % 3600) / 60;
let seconds = self.duration % 60;
if hours > 0 {
format!("{}:{:02}:{:02}", hours, minutes, seconds)
} else {
format!("{}:{:02}", minutes, seconds)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use uuid::Uuid;
#[test]
fn test_track_new() {
let track_id = Uuid::new_v4();
let track = Track::new(track_id, "Test Song".to_string(), "Test Artist".to_string(), 180);
assert_eq!(track.id, track_id);
assert_eq!(track.title, "Test Song");
assert_eq!(track.artist, "Test Artist");
assert_eq!(track.duration, 180);
assert!(track.album.is_none());
assert!(track.file_url.is_none());
assert!(track.cover_url.is_none());
}
#[test]
fn test_track_serialize() {
let track_id = Uuid::new_v4();
let track = Track::new(track_id, "Test Song".to_string(), "Test Artist".to_string(), 180);
let json = serde_json::to_string(&track).unwrap();
assert!(json.contains("\"title\":\"Test Song\""));
assert!(json.contains("\"artist\":\"Test Artist\""));
assert!(json.contains("\"duration\":180"));
}
#[test]
fn test_track_deserialize() {
let track_id = Uuid::new_v4();
let json = format!(
r#"{{"id":"{}","title":"Test Song","artist":"Test Artist","duration":180}}"#,
track_id
);
let track: Track = serde_json::from_str(&json).unwrap();
assert_eq!(track.id, track_id);
assert_eq!(track.title, "Test Song");
assert_eq!(track.artist, "Test Artist");
assert_eq!(track.duration, 180);
}
#[test]
fn test_track_validate_success() {
let track_id = Uuid::new_v4();
let track = Track::new(track_id, "Test Song".to_string(), "Test Artist".to_string(), 180);
assert!(track.validate().is_ok());
}
#[test]
fn test_track_validate_empty_title() {
let track_id = Uuid::new_v4();
let track = Track::new(track_id, "".to_string(), "Test Artist".to_string(), 180);
assert!(track.validate().is_err());
}
#[test]
fn test_track_validate_empty_artist() {
let track_id = Uuid::new_v4();
let track = Track::new(track_id, "Test Song".to_string(), "".to_string(), 180);
assert!(track.validate().is_err());
}
#[test]
fn test_track_validate_zero_duration() {
let track_id = Uuid::new_v4();
let track = Track::new(track_id, "Test Song".to_string(), "Test Artist".to_string(), 0);
assert!(track.validate().is_err());
}
#[test]
fn test_track_formatted_duration() {
let track_id = Uuid::new_v4();
let track = Track::new(track_id, "Test Song".to_string(), "Test Artist".to_string(), 125);
assert_eq!(track.formatted_duration(), "2:05");
let track_long = Track::new(track_id, "Test Song".to_string(), "Test Artist".to_string(), 3665);
assert_eq!(track_long.formatted_duration(), "1:01:05");
}
}

View file

@ -0,0 +1,118 @@
//! User type definition
//!
//! This module defines the User type and related structures.
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc};
/// User information
///
/// Represents a user in the Veza system.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct User {
/// User ID (can be database ID or UUID depending on implementation)
pub id: i64,
/// Unique username
pub username: String,
/// User email address
pub email: String,
/// Optional display name
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
/// Optional avatar URL
#[serde(skip_serializing_if = "Option::is_none")]
pub avatar_url: Option<String>,
/// User creation timestamp
#[serde(skip_serializing_if = "Option::is_none")]
pub created_at: Option<DateTime<Utc>>,
/// User last update timestamp
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<DateTime<Utc>>,
}
impl User {
/// Create a new User instance
pub fn new(id: i64, username: String, email: String) -> Self {
Self {
id,
username,
email,
display_name: None,
avatar_url: None,
created_at: None,
updated_at: None,
}
}
/// Validate user data
pub fn validate(&self) -> Result<(), String> {
if self.username.is_empty() {
return Err("Username cannot be empty".to_string());
}
if self.email.is_empty() {
return Err("Email cannot be empty".to_string());
}
if !self.email.contains('@') {
return Err("Invalid email format".to_string());
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_user_new() {
let user = User::new(1, "testuser".to_string(), "test@example.com".to_string());
assert_eq!(user.id, 1);
assert_eq!(user.username, "testuser");
assert_eq!(user.email, "test@example.com");
assert!(user.display_name.is_none());
assert!(user.avatar_url.is_none());
}
#[test]
fn test_user_serialize() {
let user = User::new(1, "testuser".to_string(), "test@example.com".to_string());
let json = serde_json::to_string(&user).unwrap();
assert!(json.contains("\"id\":1"));
assert!(json.contains("\"username\":\"testuser\""));
assert!(json.contains("\"email\":\"test@example.com\""));
}
#[test]
fn test_user_deserialize() {
let json = r#"{"id":1,"username":"testuser","email":"test@example.com"}"#;
let user: User = serde_json::from_str(json).unwrap();
assert_eq!(user.id, 1);
assert_eq!(user.username, "testuser");
assert_eq!(user.email, "test@example.com");
}
#[test]
fn test_user_validate_success() {
let user = User::new(1, "testuser".to_string(), "test@example.com".to_string());
assert!(user.validate().is_ok());
}
#[test]
fn test_user_validate_empty_username() {
let user = User::new(1, "".to_string(), "test@example.com".to_string());
assert!(user.validate().is_err());
}
#[test]
fn test_user_validate_empty_email() {
let user = User::new(1, "testuser".to_string(), "".to_string());
assert!(user.validate().is_err());
}
#[test]
fn test_user_validate_invalid_email() {
let user = User::new(1, "testuser".to_string(), "invalid-email".to_string());
assert!(user.validate().is_err());
}
}

View file

@ -0,0 +1,434 @@
//! Date utilities for Veza services
//!
//! This module provides utility functions for date and time manipulation,
//! formatting, and parsing.
use chrono::{DateTime, Utc, Local, TimeZone, NaiveDateTime, NaiveDate};
use crate::error::{CommonError, CommonResult};
/// Format a timestamp (seconds since epoch) to a string
///
/// # Examples
/// ```
/// use veza_common::utils::date::format_timestamp;
///
/// let timestamp = 1609459200; // 2021-01-01 00:00:00 UTC
/// let formatted = format_timestamp(timestamp);
/// assert!(formatted.contains("2021"));
/// ```
pub fn format_timestamp(ts: i64) -> String {
match Utc.timestamp_opt(ts, 0) {
chrono::LocalResult::Single(dt) => dt.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
_ => "Invalid timestamp".to_string(),
}
}
/// Format a timestamp to ISO 8601 format
///
/// # Examples
/// ```
/// use veza_common::utils::date::format_timestamp_iso;
///
/// let timestamp = 1609459200; // 2021-01-01 00:00:00 UTC
/// let formatted = format_timestamp_iso(timestamp);
/// assert!(formatted.contains("2021-01-01"));
/// ```
pub fn format_timestamp_iso(ts: i64) -> String {
match Utc.timestamp_opt(ts, 0) {
chrono::LocalResult::Single(dt) => dt.to_rfc3339(),
_ => "Invalid timestamp".to_string(),
}
}
/// Format a DateTime to a string with custom format
///
/// # Examples
/// ```
/// use veza_common::utils::date::format_datetime;
/// use chrono::{DateTime, Utc};
///
/// let dt = Utc::now();
/// let formatted = format_datetime(&dt, "%Y-%m-%d %H:%M:%S");
/// assert!(formatted.len() > 0);
/// ```
pub fn format_datetime(dt: &DateTime<Utc>, format: &str) -> String {
dt.format(format).to_string()
}
/// Format a DateTime to ISO 8601 format
///
/// # Examples
/// ```
/// use veza_common::utils::date::format_datetime_iso;
/// use chrono::{DateTime, Utc};
///
/// let dt = Utc::now();
/// let formatted = format_datetime_iso(&dt);
/// assert!(formatted.contains("T"));
/// ```
pub fn format_datetime_iso(dt: &DateTime<Utc>) -> String {
dt.to_rfc3339()
}
/// Format a DateTime to a relative time string (e.g., "2 hours ago", "3 days ago")
///
/// # Examples
/// ```
/// use veza_common::utils::date::format_relative_time;
/// use chrono::{DateTime, Utc, Duration};
///
/// let now = Utc::now();
/// let past = now - Duration::hours(2);
/// let relative = format_relative_time(&past, &now);
/// assert!(relative.contains("2"));
/// ```
pub fn format_relative_time(dt: &DateTime<Utc>, reference: &DateTime<Utc>) -> String {
let diff = reference.signed_duration_since(*dt);
if diff.num_seconds() < 60 {
"just now".to_string()
} else if diff.num_minutes() < 60 {
let mins = diff.num_minutes();
format!("{} minute{} ago", mins, if mins == 1 { "" } else { "s" })
} else if diff.num_hours() < 24 {
let hours = diff.num_hours();
format!("{} hour{} ago", hours, if hours == 1 { "" } else { "s" })
} else if diff.num_days() < 30 {
let days = diff.num_days();
format!("{} day{} ago", days, if days == 1 { "" } else { "s" })
} else if diff.num_days() < 365 {
let months = diff.num_days() / 30;
format!("{} month{} ago", months, if months == 1 { "" } else { "s" })
} else {
let years = diff.num_days() / 365;
format!("{} year{} ago", years, if years == 1 { "" } else { "s" })
}
}
/// Parse a date string to a DateTime<Utc>
///
/// Supports multiple formats including ISO 8601, RFC 3339, and common formats.
///
/// # Examples
/// ```
/// use veza_common::utils::date::parse_date;
///
/// let date_str = "2021-01-01T00:00:00Z";
/// let dt = parse_date(date_str).unwrap();
/// assert_eq!(dt.year(), 2021);
/// ```
pub fn parse_date(s: &str) -> CommonResult<DateTime<Utc>> {
// Try ISO 8601 / RFC 3339 first
if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
return Ok(dt.with_timezone(&Utc));
}
// Try common formats
let formats = [
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%dT%H:%M:%S",
"%Y-%m-%dT%H:%M:%S%.f",
"%Y-%m-%d",
"%d/%m/%Y",
"%m/%d/%Y",
];
for format in &formats {
if let Ok(naive_dt) = NaiveDateTime::parse_from_str(s, format) {
return Ok(naive_dt.and_utc());
}
}
// Try parsing as date only
if let Ok(naive_date) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
if let Some(dt) = naive_date.and_hms_opt(0, 0, 0) {
return Ok(dt.and_utc());
}
}
Err(CommonError::ValidationError(format!("Failed to parse date: {}", s)))
}
/// Parse a timestamp (seconds since epoch) to a DateTime<Utc>
///
/// # Examples
/// ```
/// use veza_common::utils::date::parse_timestamp;
///
/// let timestamp = 1609459200; // 2021-01-01 00:00:00 UTC
/// let dt = parse_timestamp(timestamp).unwrap();
/// assert_eq!(dt.year(), 2021);
/// ```
pub fn parse_timestamp(ts: i64) -> CommonResult<DateTime<Utc>> {
Utc.timestamp_opt(ts, 0)
.single()
.ok_or_else(|| CommonError::ValidationError(format!("Invalid timestamp: {}", ts)))
}
/// Get current timestamp (seconds since epoch)
///
/// # Examples
/// ```
/// use veza_common::utils::date::current_timestamp;
///
/// let ts = current_timestamp();
/// assert!(ts > 0);
/// ```
pub fn current_timestamp() -> i64 {
Utc::now().timestamp()
}
/// Get current DateTime<Utc>
///
/// # Examples
/// ```
/// use veza_common::utils::date::now;
///
/// let dt = now();
/// assert!(dt.year() >= 2020);
/// ```
pub fn now() -> DateTime<Utc> {
Utc::now()
}
/// Convert a DateTime<Utc> to local time
///
/// # Examples
/// ```
/// use veza_common::utils::date::to_local;
/// use chrono::{DateTime, Utc};
///
/// let utc = Utc::now();
/// let local = to_local(&utc);
/// // Local time will be different from UTC
/// ```
pub fn to_local(dt: &DateTime<Utc>) -> DateTime<Local> {
dt.with_timezone(&Local)
}
/// Check if a date is in the past
///
/// # Examples
/// ```
/// use veza_common::utils::date::is_past;
/// use chrono::{DateTime, Utc, Duration};
///
/// let past = Utc::now() - Duration::days(1);
/// assert!(is_past(&past));
/// ```
pub fn is_past(dt: &DateTime<Utc>) -> bool {
*dt < Utc::now()
}
/// Check if a date is in the future
///
/// # Examples
/// ```
/// use veza_common::utils::date::is_future;
/// use chrono::{DateTime, Utc, Duration};
///
/// let future = Utc::now() + Duration::days(1);
/// assert!(is_future(&future));
/// ```
pub fn is_future(dt: &DateTime<Utc>) -> bool {
*dt > Utc::now()
}
/// Get the difference in seconds between two dates
///
/// # Examples
/// ```
/// use veza_common::utils::date::seconds_between;
/// use chrono::{DateTime, Utc, Duration};
///
/// let dt1 = Utc::now();
/// let dt2 = dt1 + Duration::seconds(60);
/// assert_eq!(seconds_between(&dt1, &dt2), 60);
/// ```
pub fn seconds_between(dt1: &DateTime<Utc>, dt2: &DateTime<Utc>) -> i64 {
(dt2.timestamp() - dt1.timestamp()).abs()
}
/// Get the difference in days between two dates
///
/// # Examples
/// ```
/// use veza_common::utils::date::days_between;
/// use chrono::{DateTime, Utc, Duration};
///
/// let dt1 = Utc::now();
/// let dt2 = dt1 + Duration::days(7);
/// assert_eq!(days_between(&dt1, &dt2), 7);
/// ```
pub fn days_between(dt1: &DateTime<Utc>, dt2: &DateTime<Utc>) -> i64 {
dt2.signed_duration_since(*dt1).num_days().abs()
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Duration, TimeZone, Datelike, Timelike};
#[test]
fn test_format_timestamp() {
let timestamp = 1609459200; // 2021-01-01 00:00:00 UTC
let formatted = format_timestamp(timestamp);
assert!(formatted.contains("2021"));
assert!(formatted.contains("00:00:00"));
}
#[test]
fn test_format_timestamp_iso() {
let timestamp = 1609459200; // 2021-01-01 00:00:00 UTC
let formatted = format_timestamp_iso(timestamp);
assert!(formatted.contains("2021-01-01"));
assert!(formatted.contains("T"));
}
#[test]
fn test_format_datetime() {
let dt = Utc.with_ymd_and_hms(2021, 1, 1, 12, 30, 45).unwrap();
let formatted = format_datetime(&dt, "%Y-%m-%d %H:%M:%S");
assert_eq!(formatted, "2021-01-01 12:30:45");
}
#[test]
fn test_format_datetime_iso() {
let dt = Utc.with_ymd_and_hms(2021, 1, 1, 12, 30, 45).unwrap();
let formatted = format_datetime_iso(&dt);
assert!(formatted.contains("2021-01-01"));
assert!(formatted.contains("T"));
assert!(formatted.contains("12:30:45"));
}
#[test]
fn test_format_relative_time() {
let now = Utc::now();
// Just now
let just_now = now - Duration::seconds(30);
assert!(format_relative_time(&just_now, &now).contains("just now"));
// Minutes ago
let minutes_ago = now - Duration::minutes(5);
let relative = format_relative_time(&minutes_ago, &now);
assert!(relative.contains("5"));
assert!(relative.contains("minute"));
// Hours ago
let hours_ago = now - Duration::hours(2);
let relative = format_relative_time(&hours_ago, &now);
assert!(relative.contains("2"));
assert!(relative.contains("hour"));
// Days ago
let days_ago = now - Duration::days(3);
let relative = format_relative_time(&days_ago, &now);
assert!(relative.contains("3"));
assert!(relative.contains("day"));
}
#[test]
fn test_parse_date_iso() {
let date_str = "2021-01-01T00:00:00Z";
let dt = parse_date(date_str).unwrap();
assert_eq!(dt.year(), 2021);
assert_eq!(dt.month(), 1);
assert_eq!(dt.day(), 1);
}
#[test]
fn test_parse_date_common_format() {
let date_str = "2021-01-01 12:30:45";
let dt = parse_date(date_str).unwrap();
assert_eq!(dt.year(), 2021);
assert_eq!(dt.hour(), 12);
assert_eq!(dt.minute(), 30);
}
#[test]
fn test_parse_date_date_only() {
let date_str = "2021-01-01";
let dt = parse_date(date_str).unwrap();
assert_eq!(dt.year(), 2021);
assert_eq!(dt.month(), 1);
assert_eq!(dt.day(), 1);
assert_eq!(dt.hour(), 0);
}
#[test]
fn test_parse_date_invalid() {
let date_str = "invalid date";
let result = parse_date(date_str);
assert!(result.is_err());
}
#[test]
fn test_parse_timestamp() {
let timestamp = 1609459200; // 2021-01-01 00:00:00 UTC
let dt = parse_timestamp(timestamp).unwrap();
assert_eq!(dt.year(), 2021);
assert_eq!(dt.month(), 1);
assert_eq!(dt.day(), 1);
}
#[test]
fn test_current_timestamp() {
let ts = current_timestamp();
assert!(ts > 0);
assert!(ts > 1609459200); // Should be after 2021-01-01
}
#[test]
fn test_now() {
let dt = now();
assert!(dt.year() >= 2020);
}
#[test]
fn test_to_local() {
let utc = Utc.with_ymd_and_hms(2021, 1, 1, 12, 0, 0).unwrap();
let local = to_local(&utc);
// Local time should be different from UTC (unless in UTC timezone)
assert_eq!(local.year(), 2021);
}
#[test]
fn test_is_past() {
let past = Utc::now() - Duration::days(1);
assert!(is_past(&past));
let future = Utc::now() + Duration::days(1);
assert!(!is_past(&future));
}
#[test]
fn test_is_future() {
let future = Utc::now() + Duration::days(1);
assert!(is_future(&future));
let past = Utc::now() - Duration::days(1);
assert!(!is_future(&past));
}
#[test]
fn test_seconds_between() {
let dt1 = Utc.with_ymd_and_hms(2021, 1, 1, 0, 0, 0).unwrap();
let dt2 = dt1 + Duration::seconds(60);
assert_eq!(seconds_between(&dt1, &dt2), 60);
// Should work in reverse order too
assert_eq!(seconds_between(&dt2, &dt1), 60);
}
#[test]
fn test_days_between() {
let dt1 = Utc.with_ymd_and_hms(2021, 1, 1, 0, 0, 0).unwrap();
let dt2 = dt1 + Duration::days(7);
assert_eq!(days_between(&dt1, &dt2), 7);
// Should work in reverse order too
assert_eq!(days_between(&dt2, &dt1), 7);
}
}

View file

@ -0,0 +1,316 @@
//! Logging utilities for Veza services
//!
//! This module provides utility functions for structured logging across
//! all Veza services.
use std::collections::HashMap;
use std::fmt::Display;
/// Log a request with structured information
///
/// # Examples
/// ```
/// use veza_common::utils::logging::log_request;
///
/// log_request("api", "GET", "/users");
/// ```
pub fn log_request(service: &str, method: &str, path: &str) {
// Uses eprintln! for compatibility, can be replaced with tracing/log in implementations
eprintln!("[{}] {} {} - Request received", service, method, path);
}
/// Log a request with additional context
///
/// # Examples
/// ```
/// use veza_common::utils::logging::log_request_with_context;
/// use std::collections::HashMap;
///
/// let mut context = HashMap::new();
/// context.insert("user_id".to_string(), "123".to_string());
/// context.insert("ip".to_string(), "192.168.1.1".to_string());
///
/// log_request_with_context("api", "GET", "/users", &context);
/// ```
pub fn log_request_with_context(
service: &str,
method: &str,
path: &str,
context: &HashMap<String, String>,
) {
eprint!("[{}] {} {} - Request received", service, method, path);
if !context.is_empty() {
eprint!(" | Context: ");
for (key, value) in context {
eprint!("{}={} ", key, value);
}
}
eprintln!();
}
/// Log an error with context
///
/// # Examples
/// ```
/// use veza_common::utils::logging::log_error;
///
/// log_error("api", "Database connection failed", None);
/// ```
pub fn log_error(service: &str, message: &str, error: Option<&dyn Display>) {
if let Some(err) = error {
eprintln!("[ERROR] [{}] {} - Error: {}", service, message, err);
} else {
eprintln!("[ERROR] [{}] {}", service, message);
}
}
/// Log a warning with context
///
/// # Examples
/// ```
/// use veza_common::utils::logging::log_warning;
///
/// log_warning("api", "Rate limit approaching");
/// ```
pub fn log_warning(service: &str, message: &str) {
eprintln!("[WARN] [{}] {}", service, message);
}
/// Log an info message
///
/// # Examples
/// ```
/// use veza_common::utils::logging::log_info;
///
/// log_info("api", "Service started");
/// ```
pub fn log_info(service: &str, message: &str) {
eprintln!("[INFO] [{}] {}", service, message);
}
/// Log a debug message
///
/// # Examples
/// ```
/// use veza_common::utils::logging::log_debug;
///
/// log_debug("api", "Processing user request");
/// ```
pub fn log_debug(service: &str, message: &str) {
eprintln!("[DEBUG] [{}] {}", service, message);
}
/// Create a formatted log message
///
/// # Examples
/// ```
/// use veza_common::utils::logging::format_log_message;
///
/// let message = format_log_message("api", "INFO", "Service started");
/// assert!(message.contains("api"));
/// assert!(message.contains("INFO"));
/// ```
pub fn format_log_message(service: &str, level: &str, message: &str) -> String {
format!("[{}] [{}] {}", level, service, message)
}
/// Create a formatted log message with timestamp
///
/// # Examples
/// ```
/// use veza_common::utils::logging::format_log_message_with_timestamp;
///
/// let message = format_log_message_with_timestamp("api", "INFO", "Service started");
/// assert!(message.contains("api"));
/// assert!(message.contains("INFO"));
/// ```
pub fn format_log_message_with_timestamp(service: &str, level: &str, message: &str) -> String {
use chrono::Utc;
let timestamp = Utc::now().format("%Y-%m-%d %H:%M:%S UTC");
format!("[{}] [{}] [{}] {}", timestamp, level, service, message)
}
/// Create a structured log entry
///
/// # Examples
/// ```
/// use veza_common::utils::logging::StructuredLogEntry;
///
/// let entry = StructuredLogEntry::new("api", "INFO", "Service started");
/// let json = entry.to_json();
/// assert!(json.contains("api"));
/// ```
#[derive(Debug, Clone)]
pub struct StructuredLogEntry {
pub timestamp: String,
pub service: String,
pub level: String,
pub message: String,
pub context: HashMap<String, String>,
}
impl StructuredLogEntry {
/// Create a new structured log entry
pub fn new(service: &str, level: &str, message: &str) -> Self {
use chrono::Utc;
Self {
timestamp: Utc::now().to_rfc3339(),
service: service.to_string(),
level: level.to_string(),
message: message.to_string(),
context: HashMap::new(),
}
}
/// Add context to the log entry
pub fn with_context(mut self, key: String, value: String) -> Self {
self.context.insert(key, value);
self
}
/// Add multiple context fields
pub fn with_contexts(mut self, contexts: HashMap<String, String>) -> Self {
self.context.extend(contexts);
self
}
/// Convert to JSON string
pub fn to_json(&self) -> String {
use serde_json::json;
let mut json_obj = json!({
"timestamp": self.timestamp,
"service": self.service,
"level": self.level,
"message": self.message,
});
if !self.context.is_empty() {
json_obj["context"] = json!(self.context);
}
serde_json::to_string(&json_obj).unwrap_or_else(|_| "{}".to_string())
}
/// Convert to formatted string
pub fn to_string(&self) -> String {
format_log_message_with_timestamp(&self.service, &self.level, &self.message)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn test_format_log_message() {
let message = format_log_message("api", "INFO", "Test message");
assert!(message.contains("api"));
assert!(message.contains("INFO"));
assert!(message.contains("Test message"));
}
#[test]
fn test_format_log_message_with_timestamp() {
let message = format_log_message_with_timestamp("api", "INFO", "Test message");
assert!(message.contains("api"));
assert!(message.contains("INFO"));
assert!(message.contains("Test message"));
assert!(message.contains("UTC"));
}
#[test]
fn test_structured_log_entry_new() {
let entry = StructuredLogEntry::new("api", "INFO", "Test message");
assert_eq!(entry.service, "api");
assert_eq!(entry.level, "INFO");
assert_eq!(entry.message, "Test message");
assert!(!entry.timestamp.is_empty());
assert!(entry.context.is_empty());
}
#[test]
fn test_structured_log_entry_with_context() {
let entry = StructuredLogEntry::new("api", "INFO", "Test message")
.with_context("user_id".to_string(), "123".to_string());
assert_eq!(entry.context.get("user_id"), Some(&"123".to_string()));
}
#[test]
fn test_structured_log_entry_with_contexts() {
let mut contexts = HashMap::new();
contexts.insert("user_id".to_string(), "123".to_string());
contexts.insert("ip".to_string(), "192.168.1.1".to_string());
let entry = StructuredLogEntry::new("api", "INFO", "Test message")
.with_contexts(contexts);
assert_eq!(entry.context.len(), 2);
assert_eq!(entry.context.get("user_id"), Some(&"123".to_string()));
assert_eq!(entry.context.get("ip"), Some(&"192.168.1.1".to_string()));
}
#[test]
fn test_structured_log_entry_to_json() {
let entry = StructuredLogEntry::new("api", "INFO", "Test message")
.with_context("user_id".to_string(), "123".to_string());
let json = entry.to_json();
assert!(json.contains("api"));
assert!(json.contains("INFO"));
assert!(json.contains("Test message"));
assert!(json.contains("user_id"));
assert!(json.contains("123"));
}
#[test]
fn test_structured_log_entry_to_string() {
let entry = StructuredLogEntry::new("api", "INFO", "Test message");
let formatted = entry.to_string();
assert!(formatted.contains("api"));
assert!(formatted.contains("INFO"));
assert!(formatted.contains("Test message"));
}
#[test]
fn test_log_request() {
// This test just verifies the function doesn't panic
log_request("api", "GET", "/users");
}
#[test]
fn test_log_request_with_context() {
let mut context = HashMap::new();
context.insert("user_id".to_string(), "123".to_string());
// This test just verifies the function doesn't panic
log_request_with_context("api", "GET", "/users", &context);
}
#[test]
fn test_log_error() {
// This test just verifies the function doesn't panic
log_error("api", "Test error", None);
log_error("api", "Test error with details", Some(&"Connection timeout"));
}
#[test]
fn test_log_warning() {
// This test just verifies the function doesn't panic
log_warning("api", "Test warning");
}
#[test]
fn test_log_info() {
// This test just verifies the function doesn't panic
log_info("api", "Test info");
}
#[test]
fn test_log_debug() {
// This test just verifies the function doesn't panic
log_debug("api", "Test debug");
}
}

View file

@ -0,0 +1,111 @@
//! Utilities module
//!
//! This module provides utility functions and helpers.
pub mod validation;
pub mod serialization;
pub mod date;
pub mod logging;
pub use validation::*;
pub use serialization::*;
pub use date::*;
pub use logging::*;
// Re-export other utility functions
use uuid::Uuid;
/// Generate a new UUID v4
pub fn generate_uuid() -> Uuid {
Uuid::new_v4()
}
/// Format a duration in seconds to a human-readable string
pub fn format_duration(seconds: u64) -> String {
let hours = seconds / 3600;
let minutes = (seconds % 3600) / 60;
let secs = seconds % 60;
if hours > 0 {
format!("{}:{:02}:{:02}", hours, minutes, secs)
} else {
format!("{}:{:02}", minutes, secs)
}
}
/// Parse a duration string to seconds
/// Supports formats: "MM:SS", "HH:MM:SS"
pub fn parse_duration(duration_str: &str) -> Option<u64> {
let parts: Vec<&str> = duration_str.split(':').collect();
match parts.len() {
2 => {
// MM:SS format
let minutes: u64 = parts[0].parse().ok()?;
let seconds: u64 = parts[1].parse().ok()?;
Some(minutes * 60 + seconds)
}
3 => {
// HH:MM:SS format
let hours: u64 = parts[0].parse().ok()?;
let minutes: u64 = parts[1].parse().ok()?;
let seconds: u64 = parts[2].parse().ok()?;
Some(hours * 3600 + minutes * 60 + seconds)
}
_ => None,
}
}
/// Format a file size in bytes to a human-readable string
pub fn format_file_size(bytes: u64) -> String {
const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
const THRESHOLD: f64 = 1024.0;
if bytes == 0 {
return "0 B".to_string();
}
let bytes_f64 = bytes as f64;
let exp = (bytes_f64.ln() / THRESHOLD.ln()) as u32;
let exp = exp.min((UNITS.len() - 1) as u32);
let value = bytes_f64 / THRESHOLD.powi(exp as i32);
format!("{:.2} {}", value, UNITS[exp as usize])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_uuid() {
let uuid1 = generate_uuid();
let uuid2 = generate_uuid();
assert_ne!(uuid1, uuid2);
}
#[test]
fn test_format_duration() {
assert_eq!(format_duration(0), "0:00");
assert_eq!(format_duration(65), "1:05");
assert_eq!(format_duration(3665), "1:01:05");
assert_eq!(format_duration(3600), "1:00:00");
}
#[test]
fn test_parse_duration() {
assert_eq!(parse_duration("1:05"), Some(65));
assert_eq!(parse_duration("1:01:05"), Some(3665));
assert_eq!(parse_duration("invalid"), None);
assert_eq!(parse_duration("1:2:3:4"), None);
}
#[test]
fn test_format_file_size() {
assert_eq!(format_file_size(0), "0 B");
assert_eq!(format_file_size(1024), "1.00 KB");
assert_eq!(format_file_size(1048576), "1.00 MB");
assert_eq!(format_file_size(1073741824), "1.00 GB");
}
}

View file

@ -0,0 +1,383 @@
//! Serialization utilities for Veza services
//!
//! This module provides helper functions for serialization and deserialization
//! using serde, with proper error handling.
use serde::{Deserialize, Serialize};
use crate::error::{CommonError, CommonResult};
/// Serialize a value to a JSON string
///
/// # Examples
/// ```
/// use veza_common::utils::serialization::to_json;
/// use serde::Serialize;
///
/// #[derive(Serialize)]
/// struct User {
/// name: String,
/// age: u32,
/// }
///
/// let user = User { name: "John".to_string(), age: 30 };
/// let json = to_json(&user).unwrap();
/// assert!(json.contains("John"));
/// ```
pub fn to_json<T: Serialize>(value: &T) -> CommonResult<String> {
serde_json::to_string(value)
.map_err(|e| CommonError::SerializationError(format!("Failed to serialize to JSON: {}", e)))
}
/// Serialize a value to a pretty-printed JSON string
///
/// # Examples
/// ```
/// use veza_common::utils::serialization::to_json_pretty;
/// use serde::Serialize;
///
/// #[derive(Serialize)]
/// struct User {
/// name: String,
/// age: u32,
/// }
///
/// let user = User { name: "John".to_string(), age: 30 };
/// let json = to_json_pretty(&user).unwrap();
/// assert!(json.contains("John"));
/// ```
pub fn to_json_pretty<T: Serialize>(value: &T) -> CommonResult<String> {
serde_json::to_string_pretty(value)
.map_err(|e| CommonError::SerializationError(format!("Failed to serialize to pretty JSON: {}", e)))
}
/// Deserialize a value from a JSON string
///
/// # Examples
/// ```
/// use veza_common::utils::serialization::from_json;
/// use serde::Deserialize;
///
/// #[derive(Deserialize, Debug)]
/// struct User {
/// name: String,
/// age: u32,
/// }
///
/// let json = r#"{"name":"John","age":30}"#;
/// let user: User = from_json(json).unwrap();
/// assert_eq!(user.name, "John");
/// ```
pub fn from_json<'a, T: Deserialize<'a>>(s: &'a str) -> CommonResult<T> {
serde_json::from_str(s)
.map_err(|e| CommonError::SerializationError(format!("Failed to deserialize from JSON: {}", e)))
}
/// Deserialize a value from a JSON byte slice
///
/// # Examples
/// ```
/// use veza_common::utils::serialization::from_json_bytes;
/// use serde::Deserialize;
///
/// #[derive(Deserialize, Debug)]
/// struct User {
/// name: String,
/// age: u32,
/// }
///
/// let json_bytes = br#"{"name":"John","age":30}"#;
/// let user: User = from_json_bytes(json_bytes).unwrap();
/// assert_eq!(user.name, "John");
/// ```
pub fn from_json_bytes<T: for<'de> Deserialize<'de>>(bytes: &[u8]) -> CommonResult<T> {
serde_json::from_slice(bytes)
.map_err(|e| CommonError::SerializationError(format!("Failed to deserialize from JSON bytes: {}", e)))
}
/// Deserialize a value from a JSON value
///
/// # Examples
/// ```
/// use veza_common::utils::serialization::from_json_value;
/// use serde::Deserialize;
/// use serde_json::json;
///
/// #[derive(Deserialize, Debug)]
/// struct User {
/// name: String,
/// age: u32,
/// }
///
/// let json_value = json!({"name": "John", "age": 30});
/// let user: User = from_json_value(&json_value).unwrap();
/// assert_eq!(user.name, "John");
/// ```
pub fn from_json_value<T: serde::de::DeserializeOwned>(value: &serde_json::Value) -> CommonResult<T> {
serde_json::from_value(value.clone())
.map_err(|e| CommonError::SerializationError(format!("Failed to deserialize from JSON value: {}", e)))
}
/// Serialize a value to a JSON value
///
/// # Examples
/// ```
/// use veza_common::utils::serialization::to_json_value;
/// use serde::Serialize;
///
/// #[derive(Serialize)]
/// struct User {
/// name: String,
/// age: u32,
/// }
///
/// let user = User { name: "John".to_string(), age: 30 };
/// let json_value = to_json_value(&user).unwrap();
/// assert_eq!(json_value["name"], "John");
/// ```
pub fn to_json_value<T: Serialize>(value: &T) -> CommonResult<serde_json::Value> {
serde_json::to_value(value)
.map_err(|e| CommonError::SerializationError(format!("Failed to serialize to JSON value: {}", e)))
}
/// Clone a value by serializing and deserializing it
///
/// This is useful for deep cloning types that don't implement Clone.
///
/// # Examples
/// ```
/// use veza_common::utils::serialization::clone_via_json;
/// use serde::{Serialize, Deserialize};
///
/// #[derive(Serialize, Deserialize, PartialEq, Debug)]
/// struct User {
/// name: String,
/// age: u32,
/// }
///
/// let user = User { name: "John".to_string(), age: 30 };
/// let cloned = clone_via_json(&user).unwrap();
/// assert_eq!(user, cloned);
/// ```
pub fn clone_via_json<T: Serialize + for<'de> Deserialize<'de>>(value: &T) -> CommonResult<T> {
let json = to_json(value)?;
from_json(&json)
}
/// Validate that a string is valid JSON
///
/// # Examples
/// ```
/// use veza_common::utils::serialization::is_valid_json;
///
/// assert!(is_valid_json(r#"{"key":"value"}"#));
/// assert!(!is_valid_json("invalid json"));
/// ```
pub fn is_valid_json(s: &str) -> bool {
serde_json::from_str::<serde_json::Value>(s).is_ok()
}
/// Pretty print a JSON string
///
/// # Examples
/// ```
/// use veza_common::utils::serialization::pretty_print_json;
///
/// let json = r#"{"name":"John","age":30}"#;
/// let pretty = pretty_print_json(json).unwrap();
/// assert!(pretty.contains('\n')); // Pretty printed should have newlines
/// ```
pub fn pretty_print_json(s: &str) -> CommonResult<String> {
let value: serde_json::Value = from_json(s)?;
to_json_pretty(&value)
}
#[cfg(test)]
mod tests {
use super::*;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
struct TestUser {
id: u32,
name: String,
email: String,
active: bool,
}
#[test]
fn test_to_json() {
let user = TestUser {
id: 1,
name: "John Doe".to_string(),
email: "john@example.com".to_string(),
active: true,
};
let json = to_json(&user).unwrap();
assert!(json.contains("John Doe"));
assert!(json.contains("john@example.com"));
assert!(json.contains("\"id\":1"));
assert!(json.contains("\"active\":true"));
}
#[test]
fn test_to_json_pretty() {
let user = TestUser {
id: 1,
name: "John Doe".to_string(),
email: "john@example.com".to_string(),
active: true,
};
let json = to_json_pretty(&user).unwrap();
assert!(json.contains("John Doe"));
assert!(json.contains('\n')); // Pretty printed should have newlines
}
#[test]
fn test_from_json() {
let json = r#"{"id":1,"name":"John Doe","email":"john@example.com","active":true}"#;
let user: TestUser = from_json(json).unwrap();
assert_eq!(user.id, 1);
assert_eq!(user.name, "John Doe");
assert_eq!(user.email, "john@example.com");
assert_eq!(user.active, true);
}
#[test]
fn test_from_json_invalid() {
let json = r#"{"invalid":"json"}"#;
let result: Result<TestUser, _> = from_json(json);
assert!(result.is_err());
}
#[test]
fn test_from_json_bytes() {
let json_bytes = br#"{"id":1,"name":"John Doe","email":"john@example.com","active":true}"#;
let user: TestUser = from_json_bytes(json_bytes).unwrap();
assert_eq!(user.id, 1);
assert_eq!(user.name, "John Doe");
}
#[test]
fn test_from_json_value() {
let json_value = serde_json::json!({
"id": 1,
"name": "John Doe",
"email": "john@example.com",
"active": true
});
let user: TestUser = from_json_value(&json_value).unwrap();
assert_eq!(user.id, 1);
assert_eq!(user.name, "John Doe");
}
#[test]
fn test_to_json_value() {
let user = TestUser {
id: 1,
name: "John Doe".to_string(),
email: "john@example.com".to_string(),
active: true,
};
let json_value = to_json_value(&user).unwrap();
assert_eq!(json_value["id"], 1);
assert_eq!(json_value["name"], "John Doe");
assert_eq!(json_value["email"], "john@example.com");
assert_eq!(json_value["active"], true);
}
#[test]
fn test_clone_via_json() {
let user = TestUser {
id: 1,
name: "John Doe".to_string(),
email: "john@example.com".to_string(),
active: true,
};
let cloned = clone_via_json(&user).unwrap();
assert_eq!(user, cloned);
}
#[test]
fn test_is_valid_json() {
assert!(is_valid_json(r#"{"key":"value"}"#));
assert!(is_valid_json(r#"{"number":123}"#));
assert!(is_valid_json(r#"{"array":[1,2,3]}"#));
assert!(is_valid_json("null"));
assert!(is_valid_json("true"));
assert!(is_valid_json("123"));
assert!(!is_valid_json("invalid json"));
assert!(!is_valid_json("{invalid}"));
assert!(!is_valid_json(""));
}
#[test]
fn test_pretty_print_json() {
let json = r#"{"id":1,"name":"John Doe","email":"john@example.com","active":true}"#;
let pretty = pretty_print_json(json).unwrap();
assert!(pretty.contains("John Doe"));
assert!(pretty.contains('\n')); // Should be formatted with newlines
assert!(pretty.contains(" ")); // Should have indentation
}
#[test]
fn test_pretty_print_invalid_json() {
let json = "invalid json";
let result = pretty_print_json(json);
assert!(result.is_err());
}
#[test]
fn test_round_trip_serialization() {
let user = TestUser {
id: 42,
name: "Jane Smith".to_string(),
email: "jane@example.com".to_string(),
active: false,
};
// Serialize
let json = to_json(&user).unwrap();
// Deserialize
let deserialized: TestUser = from_json(&json).unwrap();
assert_eq!(user, deserialized);
}
#[test]
fn test_nested_structure() {
#[derive(Serialize, Deserialize, Debug, PartialEq)]
struct Address {
street: String,
city: String,
}
#[derive(Serialize, Deserialize, Debug, PartialEq)]
struct Person {
name: String,
address: Address,
}
let person = Person {
name: "John".to_string(),
address: Address {
street: "123 Main St".to_string(),
city: "New York".to_string(),
},
};
let json = to_json(&person).unwrap();
let deserialized: Person = from_json(&json).unwrap();
assert_eq!(person, deserialized);
}
}

View file

@ -0,0 +1,454 @@
//! Validation utilities for Veza services
//!
//! This module provides validation functions for common data types
//! such as email addresses, usernames, passwords, URLs, etc.
use crate::error::{CommonError, CommonResult};
use lazy_static::lazy_static;
use regex::Regex;
lazy_static! {
// Email validation regex - RFC 5322 compliant
static ref EMAIL_REGEX: Regex = Regex::new(
r"^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"
).unwrap();
// Username validation regex - 3-30 characters, alphanumeric and underscores
static ref USERNAME_REGEX: Regex = Regex::new(
r"^[a-zA-Z0-9_]{3,30}$"
).unwrap();
// Password validation regex - at least 8 characters, alphanumeric and special chars
// Note: We'll validate letter and number requirements separately
static ref PASSWORD_REGEX: Regex = Regex::new(
r"^[A-Za-z\d@$!%*#?&]{8,}$"
).unwrap();
// URL validation regex
static ref URL_REGEX: Regex = Regex::new(
r"^https?://(?:[-\w.])+(?:[:\d]+)?(?:/(?:[\w/_.])*)?(?:\?(?:[\w&=%.])*)?(?:#(?:[\w.])*)?$"
).unwrap();
// Phone number validation regex - international format (E.164)
// Must be 10-15 digits, optionally starting with +
static ref PHONE_REGEX: Regex = Regex::new(
r"^\+?[1-9]\d{9,14}$"
).unwrap();
}
/// Validate an email address
///
/// Returns true if the email address is valid according to RFC 5322.
///
/// # Examples
/// ```
/// use veza_common::utils::validation::validate_email;
///
/// assert!(validate_email("test@example.com"));
/// assert!(validate_email("user.name@example.co.uk"));
/// assert!(!validate_email("invalid-email"));
/// ```
pub fn validate_email(email: &str) -> bool {
if email.is_empty() || email.len() > 254 {
return false;
}
// Check basic structure: must contain @ and .
if !email.contains('@') || !email.contains('.') {
return false;
}
// Split by @ and check parts
let parts: Vec<&str> = email.split('@').collect();
if parts.len() != 2 {
return false;
}
let domain = parts[1];
// Domain must contain at least one dot after @
if !domain.contains('.') {
return false;
}
EMAIL_REGEX.is_match(email)
}
/// Validate an email address and return a Result
///
/// Returns Ok(()) if the email is valid, or an error if invalid.
pub fn validate_email_result(email: &str) -> CommonResult<()> {
if validate_email(email) {
Ok(())
} else {
Err(CommonError::ValidationError(format!("Invalid email address: {}", email)))
}
}
/// Validate a username
///
/// Username must be:
/// - 3 to 30 characters long
/// - Contains only alphanumeric characters and underscores
///
/// # Examples
/// ```
/// use veza_common::utils::validation::validate_username;
///
/// assert!(validate_username("user123"));
/// assert!(validate_username("test_user"));
/// assert!(!validate_username("ab")); // Too short
/// assert!(!validate_username("user-name")); // Contains hyphen
/// ```
pub fn validate_username(username: &str) -> bool {
USERNAME_REGEX.is_match(username)
}
/// Validate a username and return a Result
///
/// Returns Ok(()) if the username is valid, or an error if invalid.
pub fn validate_username_result(username: &str) -> CommonResult<()> {
if validate_username(username) {
Ok(())
} else {
Err(CommonError::ValidationError(
format!("Invalid username: must be 3-30 characters, alphanumeric and underscores only")
))
}
}
/// Validate a password
///
/// Password must be:
/// - At least 8 characters long
/// - Contains at least one letter
/// - Contains at least one number
/// - May contain special characters: @$!%*#?&
///
/// # Examples
/// ```
/// use veza_common::utils::validation::validate_password;
///
/// assert!(validate_password("Password123"));
/// assert!(validate_password("MyP@ssw0rd"));
/// assert!(!validate_password("short")); // Too short
/// assert!(!validate_password("NoNumbers")); // No numbers
/// ```
pub fn validate_password(password: &str) -> bool {
if password.is_empty() || password.len() < 8 {
return false;
}
// Check format (alphanumeric and allowed special chars)
if !PASSWORD_REGEX.is_match(password) {
return false;
}
// Check for at least one letter
let has_letter = password.chars().any(|c| c.is_alphabetic());
if !has_letter {
return false;
}
// Check for at least one number
let has_number = password.chars().any(|c| c.is_ascii_digit());
if !has_number {
return false;
}
true
}
/// Validate a password and return a Result
///
/// Returns Ok(()) if the password is valid, or an error if invalid.
pub fn validate_password_result(password: &str) -> CommonResult<()> {
if validate_password(password) {
Ok(())
} else {
Err(CommonError::ValidationError(
format!("Invalid password: must be at least 8 characters with at least one letter and one number")
))
}
}
/// Validate a URL
///
/// Validates HTTP/HTTPS URLs.
///
/// # Examples
/// ```
/// use veza_common::utils::validation::validate_url;
///
/// assert!(validate_url("https://example.com"));
/// assert!(validate_url("http://example.com/path?query=value"));
/// assert!(!validate_url("not-a-url"));
/// ```
pub fn validate_url(url: &str) -> bool {
if url.is_empty() {
return false;
}
URL_REGEX.is_match(url)
}
/// Validate a URL and return a Result
///
/// Returns Ok(()) if the URL is valid, or an error if invalid.
pub fn validate_url_result(url: &str) -> CommonResult<()> {
if validate_url(url) {
Ok(())
} else {
Err(CommonError::ValidationError(format!("Invalid URL: {}", url)))
}
}
/// Validate a phone number
///
/// Validates international phone number format (E.164).
///
/// # Examples
/// ```
/// use veza_common::utils::validation::validate_phone;
///
/// assert!(validate_phone("+1234567890"));
/// assert!(validate_phone("1234567890"));
/// assert!(!validate_phone("123")); // Too short
/// ```
pub fn validate_phone(phone: &str) -> bool {
if phone.is_empty() {
return false;
}
PHONE_REGEX.is_match(phone)
}
/// Validate a phone number and return a Result
///
/// Returns Ok(()) if the phone number is valid, or an error if invalid.
pub fn validate_phone_result(phone: &str) -> CommonResult<()> {
if validate_phone(phone) {
Ok(())
} else {
Err(CommonError::ValidationError(format!("Invalid phone number: {}", phone)))
}
}
/// Validate a string length
///
/// Returns true if the string length is within the specified range (inclusive).
pub fn validate_length(value: &str, min: usize, max: usize) -> bool {
let len = value.len();
len >= min && len <= max
}
/// Validate a string length and return a Result
pub fn validate_length_result(value: &str, min: usize, max: usize) -> CommonResult<()> {
if validate_length(value, min, max) {
Ok(())
} else {
Err(CommonError::ValidationError(
format!("String length must be between {} and {} characters", min, max)
))
}
}
/// Validate that a string is not empty
pub fn validate_not_empty(value: &str) -> bool {
!value.trim().is_empty()
}
/// Validate that a string is not empty and return a Result
pub fn validate_not_empty_result(value: &str) -> CommonResult<()> {
if validate_not_empty(value) {
Ok(())
} else {
Err(CommonError::ValidationError("Value cannot be empty".to_string()))
}
}
/// Validate a numeric range
///
/// Returns true if the value is within the specified range (inclusive).
pub fn validate_range<T: PartialOrd>(value: &T, min: &T, max: &T) -> bool {
value >= min && value <= max
}
/// Validate a numeric range and return a Result
pub fn validate_range_result<T: PartialOrd + std::fmt::Display + Clone>(value: T, min: T, max: T) -> CommonResult<()> {
if validate_range(&value, &min, &max) {
Ok(())
} else {
Err(CommonError::ValidationError(
format!("Value must be between {} and {}", min, max)
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_email() {
// Valid emails
assert!(validate_email("test@example.com"));
assert!(validate_email("user.name@example.com"));
assert!(validate_email("user+tag@example.co.uk"));
assert!(validate_email("user_name@example.com"));
assert!(validate_email("user123@example123.com"));
assert!(validate_email("a@b.co"));
// Invalid emails
assert!(!validate_email(""));
assert!(!validate_email("invalid-email"));
assert!(!validate_email("@example.com"));
assert!(!validate_email("test@"));
assert!(!validate_email("test@.com"));
assert!(!validate_email("test @example.com"));
assert!(!validate_email("test@example"));
assert!(!validate_email(&"a".repeat(255))); // Too long
}
#[test]
fn test_validate_email_result() {
assert!(validate_email_result("test@example.com").is_ok());
assert!(validate_email_result("invalid-email").is_err());
}
#[test]
fn test_validate_username() {
// Valid usernames
assert!(validate_username("user123"));
assert!(validate_username("test_user"));
assert!(validate_username("abc"));
assert!(validate_username("User123"));
assert!(validate_username("_user_"));
assert!(validate_username(&"a".repeat(30))); // Max length
// Invalid usernames
assert!(!validate_username(""));
assert!(!validate_username("ab")); // Too short
assert!(!validate_username(&"a".repeat(31))); // Too long
assert!(!validate_username("user-name")); // Contains hyphen
assert!(!validate_username("user name")); // Contains space
assert!(!validate_username("user.name")); // Contains dot
assert!(!validate_username("user@name")); // Contains @
}
#[test]
fn test_validate_username_result() {
assert!(validate_username_result("user123").is_ok());
assert!(validate_username_result("ab").is_err());
}
#[test]
fn test_validate_password() {
// Valid passwords
assert!(validate_password("Password123"));
assert!(validate_password("MyP@ssw0rd"));
assert!(validate_password("test123456"));
assert!(validate_password("ABCdef123"));
assert!(validate_password("Pass@123"));
// Invalid passwords
assert!(!validate_password(""));
assert!(!validate_password("short")); // Too short
assert!(!validate_password("NoNumbers")); // No numbers
assert!(!validate_password("12345678")); // No letters
assert!(!validate_password("abcdefgh")); // No numbers
}
#[test]
fn test_validate_password_result() {
assert!(validate_password_result("Password123").is_ok());
assert!(validate_password_result("short").is_err());
}
#[test]
fn test_validate_url() {
// Valid URLs
assert!(validate_url("https://example.com"));
assert!(validate_url("http://example.com"));
assert!(validate_url("https://example.com/path"));
assert!(validate_url("https://example.com/path?query=value"));
assert!(validate_url("https://example.com/path?query=value#fragment"));
assert!(validate_url("http://subdomain.example.com"));
// Invalid URLs
assert!(!validate_url(""));
assert!(!validate_url("not-a-url"));
assert!(!validate_url("example.com")); // Missing protocol
assert!(!validate_url("ftp://example.com")); // Unsupported protocol
}
#[test]
fn test_validate_url_result() {
assert!(validate_url_result("https://example.com").is_ok());
assert!(validate_url_result("not-a-url").is_err());
}
#[test]
fn test_validate_phone() {
// Valid phone numbers
assert!(validate_phone("+1234567890"));
assert!(validate_phone("1234567890"));
assert!(validate_phone("+12345678901234"));
// Invalid phone numbers
assert!(!validate_phone(""));
assert!(!validate_phone("123")); // Too short (less than 10 digits)
assert!(!validate_phone("123456789")); // Too short (9 digits)
assert!(!validate_phone("+0123456789")); // Starts with 0
assert!(!validate_phone("123-456-7890")); // Contains hyphens
assert!(!validate_phone("(123) 456-7890")); // Contains parentheses
}
#[test]
fn test_validate_phone_result() {
assert!(validate_phone_result("+1234567890").is_ok());
assert!(validate_phone_result("123").is_err());
}
#[test]
fn test_validate_length() {
assert!(validate_length("abc", 3, 5));
assert!(validate_length("abc", 3, 3));
assert!(validate_length("abcde", 3, 5));
assert!(!validate_length("ab", 3, 5)); // Too short
assert!(!validate_length("abcdef", 3, 5)); // Too long
}
#[test]
fn test_validate_length_result() {
assert!(validate_length_result("abc", 3, 5).is_ok());
assert!(validate_length_result("ab", 3, 5).is_err());
}
#[test]
fn test_validate_not_empty() {
assert!(validate_not_empty("test"));
assert!(validate_not_empty(" test "));
assert!(!validate_not_empty(""));
assert!(!validate_not_empty(" "));
}
#[test]
fn test_validate_not_empty_result() {
assert!(validate_not_empty_result("test").is_ok());
assert!(validate_not_empty_result("").is_err());
}
#[test]
fn test_validate_range() {
assert!(validate_range(&5, &1, &10));
assert!(validate_range(&1, &1, &10));
assert!(validate_range(&10, &1, &10));
assert!(!validate_range(&0, &1, &10)); // Too low
assert!(!validate_range(&11, &1, &10)); // Too high
}
#[test]
fn test_validate_range_result() {
assert!(validate_range_result(5, 1, 10).is_ok());
assert!(validate_range_result(0, 1, 10).is_err());
}
}

View file

@ -0,0 +1,429 @@
//! Common tests for Veza Common Library
//!
//! This module provides integration tests and test utilities for the Veza common library.
use veza_common::utils::{
generate_uuid, format_duration, format_file_size,
validate_email, validate_username, validate_password,
validate_email_result, validate_username_result,
to_json, from_json,
format_timestamp, parse_date, format_relative_time,
format_log_message, StructuredLogEntry,
};
use veza_common::types::{
Track,
};
use veza_common::config::{
DatabaseConfig, RedisConfig,
};
use veza_common::error::{
CommonError, ErrorResponse,
};
/// Test fixtures for common library tests
pub mod fixtures {
use veza_common::types::{User, Track, Playlist};
use veza_common::config::{DatabaseConfig, RedisConfig};
use uuid::Uuid;
use std::collections::HashMap;
/// Create a test user
pub fn create_test_user(id: i64) -> User {
User::new(id, format!("testuser{}", id), format!("test{}@example.com", id))
}
/// Create a test track
pub fn create_test_track(id: Uuid) -> Track {
Track::new(id, "Test Track".to_string(), "Test Artist".to_string(), 180)
}
/// Create a test playlist
pub fn create_test_playlist(id: Uuid, owner_id: Uuid) -> Playlist {
Playlist::new(id, "Test Playlist".to_string(), owner_id)
}
/// Create a test database configuration
pub fn create_test_database_config() -> DatabaseConfig {
DatabaseConfig::new(
"postgresql://test:test@localhost:5432/test_db".to_string(),
10
)
}
/// Create a test Redis configuration
pub fn create_test_redis_config() -> RedisConfig {
RedisConfig::new("redis://localhost:6379".to_string())
}
/// Create test context for logging
pub fn create_test_context() -> HashMap<String, String> {
let mut context = HashMap::new();
context.insert("user_id".to_string(), "123".to_string());
context.insert("ip".to_string(), "192.168.1.1".to_string());
context.insert("request_id".to_string(), Uuid::new_v4().to_string());
context
}
}
/// Test helpers for common library tests
pub mod helpers {
use veza_common::types::{ApiResponse, PaginationParams, PaginatedResponse};
/// Assert that a result is ok
pub fn assert_ok<T, E: std::fmt::Debug>(result: Result<T, E>) -> T {
result.expect("Expected Ok result")
}
/// Assert that a result is an error
pub fn assert_err<T: std::fmt::Debug, E>(result: Result<T, E>) -> E {
result.expect_err("Expected Err result")
}
/// Assert that two values are equal
pub fn assert_eq_expected<T: PartialEq + std::fmt::Debug>(
actual: T,
expected: T,
message: &str,
) {
assert_eq!(
actual, expected,
"{}: expected {:?}, got {:?}",
message, expected, actual
);
}
/// Create a test API response
pub fn create_test_api_response<T>(data: T) -> ApiResponse<T> {
ApiResponse::success(data)
}
/// Create a test error API response
pub fn create_test_error_response(message: &str) -> ApiResponse<String> {
ApiResponse::error(message.to_string())
}
/// Create test pagination parameters
pub fn create_test_pagination(page: u32, limit: u32) -> PaginationParams {
PaginationParams { page, limit }
}
/// Create test paginated response
pub fn create_test_paginated_response<T>(items: Vec<T>, total: u64) -> PaginatedResponse<T> {
PaginatedResponse::new(items, total, 1, 20)
}
}
#[cfg(test)]
mod tests {
use super::*;
use fixtures::*;
use helpers::*;
use uuid::Uuid;
#[test]
fn test_common_utilities() {
// Test UUID generation
let uuid1 = generate_uuid();
let uuid2 = generate_uuid();
assert_ne!(uuid1, uuid2);
// Test duration formatting
let formatted = format_duration(125);
assert_eq!(formatted, "2:05");
// Test file size formatting
let size = format_file_size(1024);
assert_eq!(size, "1.00 KB");
}
#[test]
fn test_validation_utilities() {
// Test email validation
assert!(validate_email("test@example.com"));
assert!(!validate_email("invalid-email"));
// Test username validation
assert!(validate_username("user123"));
assert!(!validate_username("ab")); // Too short
// Test password validation
assert!(validate_password("Password123"));
assert!(!validate_password("short")); // Too short
}
#[test]
fn test_serialization_utilities() {
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, PartialEq)]
struct TestData {
name: String,
value: i32,
}
let data = TestData {
name: "test".to_string(),
value: 42,
};
// Test serialization
let json = to_json(&data).unwrap();
assert!(json.contains("test"));
assert!(json.contains("42"));
// Test deserialization
let deserialized: TestData = from_json(&json).unwrap();
assert_eq!(data, deserialized);
}
#[test]
fn test_date_utilities() {
use chrono::{Utc, Duration, Datelike};
// Test timestamp formatting
let timestamp = 1609459200; // 2021-01-01 00:00:00 UTC
let formatted = format_timestamp(timestamp);
assert!(formatted.contains("2021"));
// Test date parsing
let date_str = "2021-01-01T00:00:00Z";
let dt = parse_date(date_str).unwrap();
assert_eq!(dt.year(), 2021);
// Test relative time
let now = Utc::now();
let past = now - Duration::hours(2);
let relative = format_relative_time(&past, &now);
assert!(relative.contains("2"));
assert!(relative.contains("hour"));
}
#[test]
fn test_logging_utilities() {
// Test log message formatting
let message = format_log_message("api", "INFO", "Test message");
assert!(message.contains("api"));
assert!(message.contains("INFO"));
assert!(message.contains("Test message"));
// Test structured log entry
let entry = StructuredLogEntry::new("api", "INFO", "Test message")
.with_context("user_id".to_string(), "123".to_string());
let json = entry.to_json();
assert!(json.contains("api"));
assert!(json.contains("user_id"));
assert!(json.contains("123"));
}
#[test]
fn test_type_utilities() {
// Test User
let user = create_test_user(1);
assert_eq!(user.id, 1);
assert_eq!(user.username, "testuser1");
assert!(user.validate().is_ok());
// Test Track
let track_id = Uuid::new_v4();
let track = create_test_track(track_id);
assert_eq!(track.id, track_id);
assert_eq!(track.title, "Test Track");
assert!(track.validate().is_ok());
// Test Playlist
let playlist_id = Uuid::new_v4();
let owner_id = Uuid::new_v4();
let playlist = create_test_playlist(playlist_id, owner_id);
assert_eq!(playlist.id, playlist_id);
assert_eq!(playlist.owner_id, owner_id);
assert!(playlist.validate().is_ok());
}
#[test]
fn test_config_utilities() {
// Test DatabaseConfig
let db_config = create_test_database_config();
assert!(db_config.validate().is_ok());
assert_eq!(db_config.host(), Some("localhost".to_string()));
assert_eq!(db_config.port(), Some(5432));
// Test RedisConfig
let redis_config = create_test_redis_config();
assert!(redis_config.validate().is_ok());
assert_eq!(redis_config.host(), Some("localhost".to_string()));
assert_eq!(redis_config.port(), Some(6379));
}
#[test]
fn test_api_response_utilities() {
// Test success response
let response = create_test_api_response("test data");
assert!(response.success);
assert!(response.data.is_some());
assert_eq!(response.data.unwrap(), "test data");
// Test error response
let error_response = create_test_error_response("Test error");
assert!(!error_response.success);
assert!(error_response.data.is_none());
assert_eq!(error_response.error, Some("Test error".to_string()));
}
#[test]
fn test_pagination_utilities() {
// Test pagination parameters
let params = create_test_pagination(2, 50);
assert_eq!(params.page, 2);
assert_eq!(params.limit, 50);
// Test paginated response
let items = vec![1, 2, 3, 4, 5];
let response = create_test_paginated_response(items.clone(), 100);
assert_eq!(response.items.len(), 5);
assert_eq!(response.total, 100);
assert_eq!(response.total_pages, 5); // 100 / 20
}
#[test]
fn test_error_handling() {
// Test CommonError
let error = CommonError::NotFound("Resource not found".to_string());
assert_eq!(error.code(), "NOT_FOUND");
assert_eq!(error.http_status_code(), 404);
assert_eq!(error.message(), "Resource not found");
// Test ErrorResponse
let error_response: ErrorResponse = (&error).into();
assert_eq!(error_response.code, "NOT_FOUND");
assert_eq!(error_response.status, 404);
}
#[test]
fn test_validation_result() {
// Test validation with Result
assert!(validate_email_result("test@example.com").is_ok());
assert!(validate_email_result("invalid-email").is_err());
assert!(validate_username_result("user123").is_ok());
assert!(validate_username_result("ab").is_err());
}
#[test]
fn test_config_validation() {
// Test DatabaseConfig validation
let mut db_config = create_test_database_config();
assert!(db_config.validate().is_ok());
// Test invalid URL
db_config.url = "invalid-url".to_string();
assert!(db_config.validate().is_err());
// Test RedisConfig validation
let mut redis_config = create_test_redis_config();
assert!(redis_config.validate().is_ok());
// Test invalid URL
redis_config.url = "invalid-url".to_string();
assert!(redis_config.validate().is_err());
}
#[test]
fn test_playlist_operations() {
let playlist_id = Uuid::new_v4();
let owner_id = Uuid::new_v4();
let track_id = Uuid::new_v4();
let mut playlist = create_test_playlist(playlist_id, owner_id);
// Test adding tracks
playlist.add_track(track_id);
assert_eq!(playlist.track_count(), 1);
assert!(!playlist.is_empty());
// Test removing tracks
playlist.remove_track(track_id);
assert_eq!(playlist.track_count(), 0);
assert!(playlist.is_empty());
}
#[test]
fn test_track_utilities() {
let track_id = Uuid::new_v4();
let track = Track::new(track_id, "Test Song".to_string(), "Test Artist".to_string(), 125);
// Test formatted duration
assert_eq!(track.formatted_duration(), "2:05");
// Test validation
assert!(track.validate().is_ok());
}
#[test]
fn test_helper_functions() {
// Test assert_ok
let ok_result: Result<i32, String> = Ok(42);
let value = assert_ok(ok_result);
assert_eq!(value, 42);
// Test assert_err
let err_result: Result<i32, String> = Err("Error".to_string());
let error = assert_err(err_result);
assert_eq!(error, "Error");
// Test assert_eq_expected
assert_eq_expected(5, 5, "Values should be equal");
}
#[test]
fn test_context_creation() {
let context = create_test_context();
assert!(context.contains_key("user_id"));
assert!(context.contains_key("ip"));
assert!(context.contains_key("request_id"));
}
#[test]
fn test_round_trip_serialization() {
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, PartialEq)]
struct TestStruct {
id: i32,
name: String,
active: bool,
}
let original = TestStruct {
id: 1,
name: "Test".to_string(),
active: true,
};
// Serialize
let json = to_json(&original).unwrap();
// Deserialize
let deserialized: TestStruct = from_json(&json).unwrap();
assert_eq!(original, deserialized);
}
#[test]
fn test_config_url_parsing() {
// Test database URL parsing
let db_config = DatabaseConfig::new(
"postgresql://user:pass@localhost:5433/mydb?sslmode=require".to_string(),
10
);
assert_eq!(db_config.host(), Some("localhost".to_string()));
assert_eq!(db_config.port(), Some(5433));
assert_eq!(db_config.database_name(), Some("mydb".to_string()));
// Test Redis URL parsing
let redis_config = RedisConfig::new("redis://user:pass@localhost:6380/1".to_string());
assert_eq!(redis_config.host(), Some("localhost".to_string()));
assert_eq!(redis_config.port(), Some(6380));
}
}