adding initial veza-common
This commit is contained in:
parent
a01d7a25ac
commit
5ecd02b1db
17 changed files with 4087 additions and 0 deletions
33
veza-common/Cargo.toml
Normal file
33
veza-common/Cargo.toml
Normal 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
372
veza-common/README.md
Normal 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`.
|
||||||
|
|
||||||
306
veza-common/src/config/database.rs
Normal file
306
veza-common/src/config/database.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
10
veza-common/src/config/mod.rs
Normal file
10
veza-common/src/config/mod.rs
Normal 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;
|
||||||
|
|
||||||
312
veza-common/src/config/redis.rs
Normal file
312
veza-common/src/config/redis.rs
Normal 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
275
veza-common/src/error.rs
Normal 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
14
veza-common/src/lib.rs
Normal 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::*;
|
||||||
|
|
||||||
154
veza-common/src/types/mod.rs
Normal file
154
veza-common/src/types/mod.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
203
veza-common/src/types/playlist.rs
Normal file
203
veza-common/src/types/playlist.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
163
veza-common/src/types/track.rs
Normal file
163
veza-common/src/types/track.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
118
veza-common/src/types/user.rs
Normal file
118
veza-common/src/types/user.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
434
veza-common/src/utils/date.rs
Normal file
434
veza-common/src/utils/date.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
316
veza-common/src/utils/logging.rs
Normal file
316
veza-common/src/utils/logging.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
111
veza-common/src/utils/mod.rs
Normal file
111
veza-common/src/utils/mod.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
383
veza-common/src/utils/serialization.rs
Normal file
383
veza-common/src/utils/serialization.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
454
veza-common/src/utils/validation.rs
Normal file
454
veza-common/src/utils/validation.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
429
veza-common/tests/common_tests.rs
Normal file
429
veza-common/tests/common_tests.rs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Reference in a new issue