init projectmycelium
This commit is contained in:
235
src/utils/data_cleanup.rs
Normal file
235
src/utils/data_cleanup.rs
Normal file
@@ -0,0 +1,235 @@
|
||||
//! Data cleanup utilities for fixing duplicate nodes and inconsistent data
|
||||
//! This module provides functions to clean up existing user data files
|
||||
|
||||
use crate::services::user_persistence::UserPersistence;
|
||||
use crate::models::user::FarmNode;
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
use chrono::Utc;
|
||||
|
||||
/// Cleanup utility for fixing data inconsistencies
|
||||
pub struct DataCleanup;
|
||||
|
||||
impl DataCleanup {
|
||||
/// Remove duplicate nodes from a user's data
|
||||
pub fn remove_duplicate_nodes(user_email: &str) -> Result<usize, String> {
|
||||
let mut persistent_data = UserPersistence::load_user_data(user_email)
|
||||
.ok_or("User data not found")?;
|
||||
|
||||
let original_count = persistent_data.nodes.len();
|
||||
|
||||
// Group nodes by grid_node_id and node ID pattern
|
||||
let mut node_groups: HashMap<String, Vec<(usize, FarmNode)>> = HashMap::new();
|
||||
|
||||
for (index, node) in persistent_data.nodes.iter().enumerate() {
|
||||
let group_key = if let Some(ref grid_id) = node.grid_node_id {
|
||||
format!("grid_{}", grid_id)
|
||||
} else {
|
||||
// Extract grid ID from node ID if possible
|
||||
if node.id.starts_with("grid_node_") {
|
||||
let parts: Vec<&str> = node.id.split('_').collect();
|
||||
if parts.len() >= 3 {
|
||||
format!("grid_{}", parts[2])
|
||||
} else {
|
||||
format!("unique_{}", node.id)
|
||||
}
|
||||
} else {
|
||||
format!("unique_{}", node.id)
|
||||
}
|
||||
};
|
||||
|
||||
node_groups.entry(group_key).or_insert_with(Vec::new).push((index, node.clone()));
|
||||
}
|
||||
|
||||
// Keep the best node from each group
|
||||
let mut nodes_to_keep = Vec::new();
|
||||
let mut duplicates_removed = 0;
|
||||
|
||||
for (group_key, mut group_nodes) in node_groups {
|
||||
if group_nodes.len() > 1 {
|
||||
|
||||
// Sort by quality: prefer nodes with slice combinations, marketplace SLA, and recent updates
|
||||
group_nodes.sort_by(|a, b| {
|
||||
let score_a = Self::calculate_node_quality_score(&a.1);
|
||||
let score_b = Self::calculate_node_quality_score(&b.1);
|
||||
score_b.partial_cmp(&score_a).unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
|
||||
// Keep the best node, merge data from others if needed
|
||||
let mut best_node = group_nodes[0].1.clone();
|
||||
|
||||
// Merge slice data from other nodes if the best node is missing it
|
||||
for (_, other_node) in &group_nodes[1..] {
|
||||
if best_node.available_combinations.is_empty() && !other_node.available_combinations.is_empty() {
|
||||
best_node.available_combinations = other_node.available_combinations.clone();
|
||||
best_node.total_base_slices = other_node.total_base_slices;
|
||||
best_node.slice_last_calculated = other_node.slice_last_calculated;
|
||||
}
|
||||
|
||||
if best_node.marketplace_sla.is_none() && other_node.marketplace_sla.is_some() {
|
||||
best_node.marketplace_sla = other_node.marketplace_sla.clone();
|
||||
}
|
||||
|
||||
if best_node.rental_options.is_none() && other_node.rental_options.is_some() {
|
||||
best_node.rental_options = other_node.rental_options.clone();
|
||||
}
|
||||
}
|
||||
|
||||
nodes_to_keep.push(best_node);
|
||||
duplicates_removed += group_nodes.len() - 1;
|
||||
} else {
|
||||
// Single node, keep as is
|
||||
nodes_to_keep.push(group_nodes[0].1.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Update the persistent data
|
||||
persistent_data.nodes = nodes_to_keep;
|
||||
|
||||
// Save the cleaned data
|
||||
UserPersistence::save_user_data(&persistent_data)
|
||||
.map_err(|e| format!("Failed to save cleaned data: {}", e))?;
|
||||
|
||||
let final_count = persistent_data.nodes.len();
|
||||
|
||||
Ok(duplicates_removed)
|
||||
}
|
||||
|
||||
/// Calculate a quality score for a node (higher is better)
|
||||
fn calculate_node_quality_score(node: &FarmNode) -> f32 {
|
||||
let mut score = 0.0;
|
||||
|
||||
// Prefer nodes with slice combinations
|
||||
if !node.available_combinations.is_empty() {
|
||||
score += 10.0;
|
||||
}
|
||||
|
||||
// Prefer nodes with marketplace SLA
|
||||
if node.marketplace_sla.is_some() {
|
||||
score += 5.0;
|
||||
}
|
||||
|
||||
// Prefer nodes with rental options
|
||||
if node.rental_options.is_some() {
|
||||
score += 3.0;
|
||||
}
|
||||
|
||||
// Prefer nodes with recent slice calculations
|
||||
if node.slice_last_calculated.is_some() {
|
||||
score += 2.0;
|
||||
}
|
||||
|
||||
// Prefer nodes with grid data
|
||||
if node.grid_data.is_some() {
|
||||
score += 1.0;
|
||||
}
|
||||
|
||||
// Prefer nodes with higher total base slices
|
||||
score += node.total_base_slices as f32 * 0.1;
|
||||
|
||||
score
|
||||
}
|
||||
|
||||
/// Clean up all user data files
|
||||
pub fn cleanup_all_users() -> Result<HashMap<String, usize>, String> {
|
||||
let mut results = HashMap::new();
|
||||
|
||||
// Get all user files from ./user_data/
|
||||
if let Ok(entries) = std::fs::read_dir("./user_data/") {
|
||||
for entry in entries.flatten() {
|
||||
if let Some(filename) = entry.file_name().to_str() {
|
||||
if filename.ends_with(".json")
|
||||
&& filename.contains("_at_")
|
||||
&& !filename.contains("_cart")
|
||||
&& filename != "session_data.json"
|
||||
{
|
||||
let user_email = filename
|
||||
.trim_end_matches(".json")
|
||||
.replace("_at_", "@")
|
||||
.replace("_", ".");
|
||||
|
||||
match Self::remove_duplicate_nodes(&user_email) {
|
||||
Ok(duplicates_removed) => {
|
||||
if duplicates_removed > 0 {
|
||||
results.insert(user_email, duplicates_removed);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Validate and fix slice data consistency
|
||||
pub fn validate_slice_data(user_email: &str) -> Result<bool, String> {
|
||||
let mut persistent_data = UserPersistence::load_user_data(user_email)
|
||||
.ok_or("User data not found")?;
|
||||
|
||||
let mut changes_made = false;
|
||||
|
||||
for node in &mut persistent_data.nodes {
|
||||
// Ensure nodes with slice combinations have marketplace SLA
|
||||
if !node.available_combinations.is_empty() && node.marketplace_sla.is_none() {
|
||||
node.marketplace_sla = Some(crate::models::user::MarketplaceSLA {
|
||||
id: format!("sla-{}", node.id),
|
||||
name: "Standard Marketplace SLA".to_string(),
|
||||
uptime_guarantee: node.uptime_percentage,
|
||||
response_time_hours: 24,
|
||||
resolution_time_hours: 48,
|
||||
penalty_rate: 0.01,
|
||||
uptime_guarantee_percentage: node.uptime_percentage,
|
||||
bandwidth_guarantee_mbps: node.capacity.bandwidth_mbps as f32,
|
||||
base_slice_price: node.slice_pricing
|
||||
.as_ref()
|
||||
.and_then(|pricing| pricing.get("base_price_per_hour"))
|
||||
.and_then(|price| price.as_str())
|
||||
.and_then(|price_str| rust_decimal::Decimal::from_str(price_str).ok())
|
||||
.unwrap_or_else(|| rust_decimal::Decimal::try_from(0.50).unwrap_or_default()),
|
||||
last_updated: Utc::now(),
|
||||
});
|
||||
changes_made = true;
|
||||
}
|
||||
|
||||
// Ensure nodes with slice combinations have rental options
|
||||
if !node.available_combinations.is_empty() && node.rental_options.is_none() {
|
||||
let rental_options = crate::models::user::NodeRentalOptions {
|
||||
full_node_available: false,
|
||||
slice_formats: vec!["1x1".to_string(), "2x2".to_string(), "4x4".to_string()],
|
||||
pricing: crate::models::user::FullNodePricing {
|
||||
monthly_cost: rust_decimal::Decimal::try_from(100.0).unwrap_or_default(),
|
||||
setup_fee: Some(rust_decimal::Decimal::try_from(10.0).unwrap_or_default()),
|
||||
deposit_required: Some(rust_decimal::Decimal::try_from(50.0).unwrap_or_default()),
|
||||
hourly: rust_decimal::Decimal::try_from(4.17).unwrap_or_default(),
|
||||
daily: rust_decimal::Decimal::try_from(3.33).unwrap_or_default(),
|
||||
monthly: rust_decimal::Decimal::try_from(100.0).unwrap_or_default(),
|
||||
yearly: rust_decimal::Decimal::try_from(1000.0).unwrap_or_default(),
|
||||
daily_discount_percent: 20.0,
|
||||
monthly_discount_percent: 10.0,
|
||||
yearly_discount_percent: 17.0,
|
||||
auto_calculate: true,
|
||||
},
|
||||
slice_rental_enabled: true,
|
||||
full_node_rental_enabled: false,
|
||||
full_node_pricing: None,
|
||||
minimum_rental_days: 1,
|
||||
maximum_rental_days: Some(365),
|
||||
auto_renewal_enabled: true,
|
||||
};
|
||||
node.rental_options = Some(serde_json::to_value(rental_options).unwrap_or_default());
|
||||
changes_made = true;
|
||||
}
|
||||
}
|
||||
|
||||
if changes_made {
|
||||
UserPersistence::save_user_data(&persistent_data)
|
||||
.map_err(|e| format!("Failed to save validated data: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(changes_made)
|
||||
}
|
||||
}
|
207
src/utils/data_validator.rs
Normal file
207
src/utils/data_validator.rs
Normal file
@@ -0,0 +1,207 @@
|
||||
//! Data validation and repair utilities for JSON user data
|
||||
//! Industry standard approach to handle schema mismatches
|
||||
|
||||
use serde_json::{Value, Map};
|
||||
|
||||
/// Comprehensive data validator and repairer for user JSON files
|
||||
pub struct DataValidator;
|
||||
|
||||
impl DataValidator {
|
||||
/// Validates and repairs user data JSON to match current schema
|
||||
pub fn validate_and_repair_user_data(json_str: &str) -> Result<String, String> {
|
||||
let mut value: Value = serde_json::from_str(json_str)
|
||||
.map_err(|e| format!("Invalid JSON: {}", e))?;
|
||||
|
||||
if let Value::Object(ref mut obj) = value {
|
||||
Self::repair_user_activities(obj)?;
|
||||
Self::repair_farmer_settings(obj)?;
|
||||
Self::ensure_required_fields(obj)?;
|
||||
}
|
||||
|
||||
serde_json::to_string_pretty(&value)
|
||||
.map_err(|e| format!("Failed to serialize repaired JSON: {}", e))
|
||||
}
|
||||
|
||||
/// Repairs user activities to match ActivityType enum
|
||||
fn repair_user_activities(obj: &mut Map<String, Value>) -> Result<(), String> {
|
||||
if let Some(Value::Array(activities)) = obj.get_mut("user_activities") {
|
||||
for activity in activities.iter_mut() {
|
||||
if let Value::Object(ref mut activity_obj) = activity {
|
||||
// Fix activity_type variants
|
||||
if let Some(Value::String(activity_type)) = activity_obj.get_mut("activity_type") {
|
||||
*activity_type = Self::normalize_activity_type(activity_type);
|
||||
}
|
||||
|
||||
// Ensure category field exists
|
||||
if !activity_obj.contains_key("category") {
|
||||
let category = Self::infer_category_from_activity_type(
|
||||
activity_obj.get("activity_type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Service")
|
||||
);
|
||||
activity_obj.insert("category".to_string(), Value::String(category));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Repairs farmer settings to include all required fields
|
||||
fn repair_farmer_settings(obj: &mut Map<String, Value>) -> Result<(), String> {
|
||||
if let Some(Value::Object(ref mut farmer_settings)) = obj.get_mut("farmer_settings") {
|
||||
// Ensure minimum_deployment_duration exists
|
||||
if !farmer_settings.contains_key("minimum_deployment_duration") {
|
||||
farmer_settings.insert(
|
||||
"minimum_deployment_duration".to_string(),
|
||||
Value::Number(serde_json::Number::from(24))
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure preferred_regions exists
|
||||
if !farmer_settings.contains_key("preferred_regions") {
|
||||
farmer_settings.insert(
|
||||
"preferred_regions".to_string(),
|
||||
Value::Array(vec![
|
||||
Value::String("NA".to_string()),
|
||||
Value::String("EU".to_string())
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ensures all required top-level fields exist
|
||||
fn ensure_required_fields(obj: &mut Map<String, Value>) -> Result<(), String> {
|
||||
let required_fields = vec![
|
||||
("user_email", Value::String("unknown@example.com".to_string())),
|
||||
("wallet_balance", Value::String("0.0".to_string())),
|
||||
("transactions", Value::Array(vec![])),
|
||||
("services", Value::Array(vec![])),
|
||||
("service_requests", Value::Array(vec![])),
|
||||
("apps", Value::Array(vec![])),
|
||||
("app_deployments", Value::Array(vec![])),
|
||||
("nodes", Value::Array(vec![])),
|
||||
("farmer_earnings", Value::Array(vec![])),
|
||||
("user_activities", Value::Array(vec![])),
|
||||
("pool_positions", Value::Object(Map::new())),
|
||||
];
|
||||
|
||||
for (field, default_value) in required_fields {
|
||||
if !obj.contains_key(field) {
|
||||
obj.insert(field.to_string(), default_value);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Normalizes activity type to match enum variants
|
||||
fn normalize_activity_type(activity_type: &str) -> String {
|
||||
match activity_type {
|
||||
"ServiceProgress" => "ServiceCreated".to_string(),
|
||||
"AppDeployment" => "Deployment".to_string(),
|
||||
"AppCreated" => "AppPublished".to_string(),
|
||||
"NodeCreated" => "NodeAdded".to_string(),
|
||||
"NodeModified" => "NodeUpdated".to_string(),
|
||||
"WalletDeposit" | "WalletWithdraw" | "Payment" => "WalletTransaction".to_string(),
|
||||
"ProfileChanged" => "ProfileUpdate".to_string(),
|
||||
"ConfigChange" => "SettingsChange".to_string(),
|
||||
"BrowseMarketplace" => "MarketplaceView".to_string(),
|
||||
"SliceCreation" => "SliceCreated".to_string(),
|
||||
"SliceAssignment" => "SliceAllocated".to_string(),
|
||||
"SliceRemoval" => "SliceReleased".to_string(),
|
||||
// Valid variants pass through unchanged
|
||||
"Login" | "Purchase" | "Deployment" | "ServiceCreated" | "AppPublished" |
|
||||
"NodeAdded" | "NodeUpdated" | "WalletTransaction" | "ProfileUpdate" |
|
||||
"SettingsChange" | "MarketplaceView" | "SliceCreated" | "SliceAllocated" |
|
||||
"SliceReleased" => activity_type.to_string(),
|
||||
// Default fallback
|
||||
_ => "ProfileUpdate".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Infers category from activity type
|
||||
fn infer_category_from_activity_type(activity_type: &str) -> String {
|
||||
match activity_type {
|
||||
"ServiceCreated" => "Service".to_string(),
|
||||
"AppPublished" | "Deployment" => "App".to_string(),
|
||||
"NodeAdded" | "NodeUpdated" | "SliceCreated" | "SliceAllocated" | "SliceReleased" => "Farming".to_string(),
|
||||
"WalletTransaction" => "Finance".to_string(),
|
||||
"Login" | "ProfileUpdate" | "SettingsChange" => "Account".to_string(),
|
||||
"Purchase" | "MarketplaceView" => "Marketplace".to_string(),
|
||||
_ => "General".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates all user data files in the user_data directory
|
||||
pub fn validate_all_user_files() -> Result<Vec<String>, String> {
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
let user_data_dir = Path::new("user_data");
|
||||
if !user_data_dir.exists() {
|
||||
return Err("user_data directory not found".to_string());
|
||||
}
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
for entry in fs::read_dir(user_data_dir).map_err(|e| format!("Failed to read directory: {}", e))? {
|
||||
let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.extension().and_then(|s| s.to_str()) == Some("json") {
|
||||
let filename = path.file_name().unwrap().to_str().unwrap();
|
||||
// Only process per-user files: e.g., user1_at_example_com.json
|
||||
let is_user_file = filename.ends_with(".json")
|
||||
&& filename.contains("_at_")
|
||||
&& !filename.contains("_cart")
|
||||
&& filename != "session_data.json";
|
||||
if !is_user_file { continue; }
|
||||
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(content) => {
|
||||
match Self::validate_and_repair_user_data(&content) {
|
||||
Ok(repaired_content) => {
|
||||
// Write back the repaired content
|
||||
if let Err(e) = fs::write(&path, repaired_content) {
|
||||
results.push(format!("❌ {}: Failed to write repaired data: {}", filename, e));
|
||||
} else {
|
||||
results.push(format!("✅ {}: Successfully validated and repaired", filename));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
results.push(format!("❌ {}: Validation failed: {}", filename, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
results.push(format!("❌ {}: Failed to read file: {}", filename, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_activity_type_normalization() {
|
||||
assert_eq!(DataValidator::normalize_activity_type("ServiceProgress"), "ServiceCreated");
|
||||
assert_eq!(DataValidator::normalize_activity_type("AppDeployment"), "Deployment");
|
||||
assert_eq!(DataValidator::normalize_activity_type("Login"), "Login");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_category_inference() {
|
||||
assert_eq!(DataValidator::infer_category_from_activity_type("ServiceCreated"), "Service");
|
||||
assert_eq!(DataValidator::infer_category_from_activity_type("Deployment"), "App");
|
||||
assert_eq!(DataValidator::infer_category_from_activity_type("NodeAdded"), "Farming");
|
||||
}
|
||||
}
|
150
src/utils/mod.rs
Normal file
150
src/utils/mod.rs
Normal file
@@ -0,0 +1,150 @@
|
||||
use actix_web::{web, HttpResponse, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::error::Error as StdError;
|
||||
use tera::{Context, Tera};
|
||||
|
||||
// Data validation module
|
||||
pub mod data_validator;
|
||||
pub use data_validator::DataValidator;
|
||||
|
||||
// Data cleanup module
|
||||
pub mod data_cleanup;
|
||||
pub use data_cleanup::DataCleanup;
|
||||
|
||||
// Response builder module
|
||||
pub mod response_builder;
|
||||
pub use response_builder::ResponseBuilder;
|
||||
|
||||
// Commenting out PDF module for now since we're using a simpler approach
|
||||
// pub mod pdf;
|
||||
|
||||
/// Renders a template with the given context using ResponseBuilder pattern
|
||||
pub fn render_template(
|
||||
tmpl: &web::Data<Tera>,
|
||||
template_name: &str,
|
||||
context: &Context,
|
||||
) -> Result<HttpResponse> {
|
||||
let rendered = match tmpl.render(template_name, context) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
// Print a detailed error report to stderr for debugging
|
||||
eprintln!("================ Tera Render Error ================");
|
||||
eprintln!("Template: {}", template_name);
|
||||
eprintln!("Display: {}", e);
|
||||
eprintln!("Debug: {:#?}", e);
|
||||
// Walk the error source chain, if any
|
||||
let mut src_opt = StdError::source(&e);
|
||||
while let Some(src) = src_opt {
|
||||
eprintln!("Caused by: {}", src);
|
||||
src_opt = StdError::source(src);
|
||||
}
|
||||
eprintln!("===================================================");
|
||||
return Err(actix_web::error::ErrorInternalServerError(format!(
|
||||
"Failed to render '{}': {}",
|
||||
template_name, e
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
crate::utils::response_builder::ResponseBuilder::ok()
|
||||
.html(rendered)
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Registers custom functions with Tera
|
||||
pub fn register_tera_functions(tera: &mut Tera) {
|
||||
tera.register_function("format_date", format_date);
|
||||
tera.register_function("active_class", active_class);
|
||||
tera.register_filter("format_decimal", format_decimal);
|
||||
}
|
||||
|
||||
/// Custom Tera function to format dates
|
||||
fn format_date(args: &HashMap<String, Value>) -> tera::Result<Value> {
|
||||
let date = match args.get("date") {
|
||||
Some(val) => val
|
||||
.as_str()
|
||||
.ok_or_else(|| tera::Error::msg("Date must be a string"))?,
|
||||
None => return Err(tera::Error::msg("Date is required")),
|
||||
};
|
||||
|
||||
let format = match args.get("format") {
|
||||
Some(val) => val.as_str().unwrap_or("%Y-%m-%d %H:%M:%S"),
|
||||
None => "%Y-%m-%d %H:%M:%S",
|
||||
};
|
||||
|
||||
// Parse the date string
|
||||
let datetime = match DateTime::parse_from_rfc3339(date) {
|
||||
Ok(dt) => dt.with_timezone(&Utc),
|
||||
Err(_) => {
|
||||
// Try parsing as a timestamp
|
||||
match date.parse::<i64>() {
|
||||
Ok(ts) => DateTime::from_timestamp(ts, 0)
|
||||
.ok_or_else(|| tera::Error::msg("Invalid timestamp"))?,
|
||||
Err(_) => return Err(tera::Error::msg("Invalid date format")),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Format the date
|
||||
let formatted = datetime.format(format).to_string();
|
||||
|
||||
Ok(Value::String(formatted))
|
||||
}
|
||||
|
||||
/// Custom Tera function to set active class for navigation
|
||||
fn active_class(args: &HashMap<String, Value>) -> tera::Result<Value> {
|
||||
let current = match args.get("current") {
|
||||
Some(val) => val
|
||||
.as_str()
|
||||
.ok_or_else(|| tera::Error::msg("Current must be a string"))?,
|
||||
None => return Err(tera::Error::msg("Current is required")),
|
||||
};
|
||||
|
||||
let page = match args.get("page") {
|
||||
Some(val) => val
|
||||
.as_str()
|
||||
.ok_or_else(|| tera::Error::msg("Page must be a string"))?,
|
||||
None => return Err(tera::Error::msg("Page is required")),
|
||||
};
|
||||
|
||||
let class = match args.get("class") {
|
||||
Some(val) => val.as_str().unwrap_or("active"),
|
||||
None => "active",
|
||||
};
|
||||
|
||||
if current == page {
|
||||
Ok(Value::String(class.to_string()))
|
||||
} else {
|
||||
Ok(Value::String("".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom Tera filter to format decimal numbers
|
||||
fn format_decimal(value: &Value, args: &HashMap<String, Value>) -> tera::Result<Value> {
|
||||
let number = match value {
|
||||
Value::Number(n) => {
|
||||
if let Some(f) = n.as_f64() {
|
||||
f
|
||||
} else if let Some(i) = n.as_i64() {
|
||||
i as f64
|
||||
} else {
|
||||
return Err(tera::Error::msg("Invalid number format"));
|
||||
}
|
||||
}
|
||||
Value::String(s) => {
|
||||
s.parse::<f64>()
|
||||
.map_err(|_| tera::Error::msg("Cannot parse string as number"))?
|
||||
}
|
||||
_ => return Err(tera::Error::msg("Value must be a number or string")),
|
||||
};
|
||||
|
||||
let precision = args
|
||||
.get("precision")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(1) as usize;
|
||||
|
||||
let formatted = format!("{:.prec$}", number, prec = precision);
|
||||
Ok(Value::String(formatted))
|
||||
}
|
693
src/utils/response_builder.rs
Normal file
693
src/utils/response_builder.rs
Normal file
@@ -0,0 +1,693 @@
|
||||
use actix_web::{HttpResponse, Result as ActixResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Centralized response builder for consistent API responses
|
||||
///
|
||||
/// This builder consolidates all scattered HttpResponse construction throughout
|
||||
/// the Project Mycelium codebase into a single source of truth, following
|
||||
/// the established builder pattern architecture.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```rust,ignore
|
||||
/// // Success responses
|
||||
/// ResponseBuilder::success().data(user).build()
|
||||
/// ResponseBuilder::success().message("User created").build()
|
||||
///
|
||||
/// // Error responses
|
||||
/// ResponseBuilder::error().message("Invalid input").status(400).build()
|
||||
/// ResponseBuilder::not_found().message("User not found").build()
|
||||
///
|
||||
/// // Paginated responses
|
||||
/// ResponseBuilder::paginated(users, 1, 100, 10).build()
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResponseBuilder {
|
||||
success: bool,
|
||||
status_code: u16,
|
||||
message: Option<String>,
|
||||
data: Option<Value>,
|
||||
errors: Vec<String>,
|
||||
metadata: HashMap<String, Value>,
|
||||
content_type: Option<String>,
|
||||
html_body: Option<String>,
|
||||
raw_json: Option<Value>,
|
||||
}
|
||||
|
||||
/// Standard API response structure
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ApiResponse {
|
||||
pub success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub errors: Vec<String>,
|
||||
#[serde(skip_serializing_if = "HashMap::is_empty")]
|
||||
pub metadata: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
/// Pagination metadata structure
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct PaginationMeta {
|
||||
pub page: u32,
|
||||
pub per_page: u32,
|
||||
pub total: u32,
|
||||
pub total_pages: u32,
|
||||
pub has_next: bool,
|
||||
pub has_prev: bool,
|
||||
}
|
||||
|
||||
impl ResponseBuilder {
|
||||
/// Creates a new response builder with default values
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
success: true,
|
||||
status_code: 200,
|
||||
message: None,
|
||||
data: None,
|
||||
errors: Vec::new(),
|
||||
metadata: HashMap::new(),
|
||||
content_type: None,
|
||||
html_body: None,
|
||||
raw_json: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a success response builder
|
||||
pub fn success() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
|
||||
/// Creates an OK response builder
|
||||
pub fn ok() -> Self {
|
||||
Self {
|
||||
success: true,
|
||||
status_code: 200,
|
||||
message: None,
|
||||
data: None,
|
||||
errors: Vec::new(),
|
||||
metadata: HashMap::new(),
|
||||
content_type: None,
|
||||
html_body: None,
|
||||
raw_json: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an error response builder
|
||||
pub fn error() -> Self {
|
||||
Self {
|
||||
success: false,
|
||||
status_code: 400,
|
||||
message: None,
|
||||
data: None,
|
||||
errors: Vec::new(),
|
||||
metadata: HashMap::new(),
|
||||
content_type: None,
|
||||
html_body: None,
|
||||
raw_json: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a bad request response builder
|
||||
pub fn bad_request() -> Self {
|
||||
Self {
|
||||
success: false,
|
||||
status_code: 400,
|
||||
message: Some("Bad request".to_string()),
|
||||
data: None,
|
||||
errors: Vec::new(),
|
||||
metadata: HashMap::new(),
|
||||
content_type: None,
|
||||
html_body: None,
|
||||
raw_json: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a not found response builder
|
||||
pub fn not_found() -> Self {
|
||||
Self {
|
||||
success: false,
|
||||
status_code: 404,
|
||||
message: Some("Resource not found".to_string()),
|
||||
data: None,
|
||||
errors: Vec::new(),
|
||||
metadata: HashMap::new(),
|
||||
content_type: None,
|
||||
html_body: None,
|
||||
raw_json: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an unauthorized response builder
|
||||
pub fn unauthorized() -> Self {
|
||||
Self {
|
||||
success: false,
|
||||
status_code: 401,
|
||||
message: Some("Unauthorized access".to_string()),
|
||||
data: None,
|
||||
errors: Vec::new(),
|
||||
metadata: HashMap::new(),
|
||||
content_type: None,
|
||||
html_body: None,
|
||||
raw_json: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a forbidden response builder
|
||||
pub fn forbidden() -> Self {
|
||||
Self {
|
||||
success: false,
|
||||
status_code: 403,
|
||||
message: Some("Forbidden".to_string()),
|
||||
data: None,
|
||||
errors: Vec::new(),
|
||||
metadata: HashMap::new(),
|
||||
content_type: None,
|
||||
html_body: None,
|
||||
raw_json: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a payment required response builder
|
||||
pub fn payment_required() -> Self {
|
||||
Self {
|
||||
success: false,
|
||||
status_code: 402,
|
||||
message: Some("Payment Required".to_string()),
|
||||
data: None,
|
||||
errors: Vec::new(),
|
||||
metadata: HashMap::new(),
|
||||
content_type: None,
|
||||
html_body: None,
|
||||
raw_json: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an internal server error response builder
|
||||
pub fn internal_error() -> Self {
|
||||
Self {
|
||||
success: false,
|
||||
status_code: 500,
|
||||
message: Some("Internal server error".to_string()),
|
||||
data: None,
|
||||
errors: Vec::new(),
|
||||
metadata: HashMap::new(),
|
||||
content_type: None,
|
||||
html_body: None,
|
||||
raw_json: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a redirect response builder
|
||||
pub fn redirect<S: Into<String>>(location: S) -> Self {
|
||||
let mut metadata = HashMap::new();
|
||||
metadata.insert("redirect_location".to_string(), json!(location.into()));
|
||||
|
||||
Self {
|
||||
success: true,
|
||||
status_code: 302,
|
||||
message: None,
|
||||
data: None,
|
||||
errors: Vec::new(),
|
||||
metadata,
|
||||
content_type: None,
|
||||
html_body: None,
|
||||
raw_json: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a cookie to the response builder
|
||||
pub fn cookie(mut self, cookie: actix_web::cookie::Cookie) -> Self {
|
||||
self.metadata.insert("response_cookie".to_string(), json!({
|
||||
"name": cookie.name(),
|
||||
"value": cookie.value(),
|
||||
"path": cookie.path(),
|
||||
"http_only": cookie.http_only(),
|
||||
"secure": cookie.secure(),
|
||||
"max_age": cookie.max_age().map(|d| d.whole_seconds())
|
||||
}));
|
||||
self
|
||||
}
|
||||
|
||||
/// Creates a paginated response builder
|
||||
pub fn paginated<T: Serialize>(data: Vec<T>, page: u32, per_page: u32, total: u32) -> Self {
|
||||
let total_pages = (total + per_page - 1) / per_page;
|
||||
let has_next = page < total_pages;
|
||||
let has_prev = page > 1;
|
||||
|
||||
let pagination_meta = PaginationMeta {
|
||||
page,
|
||||
per_page,
|
||||
total,
|
||||
total_pages,
|
||||
has_next,
|
||||
has_prev,
|
||||
};
|
||||
|
||||
let mut metadata = HashMap::new();
|
||||
metadata.insert("pagination".to_string(), json!(pagination_meta));
|
||||
|
||||
Self {
|
||||
success: true,
|
||||
status_code: 200,
|
||||
message: None,
|
||||
data: Some(json!(data)),
|
||||
errors: Vec::new(),
|
||||
metadata,
|
||||
content_type: None,
|
||||
html_body: None,
|
||||
raw_json: None,
|
||||
}
|
||||
}
|
||||
|
||||
// Builder methods for fluent interface
|
||||
|
||||
pub fn status(mut self, code: u16) -> Self {
|
||||
self.status_code = code;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn message<S: Into<String>>(mut self, msg: S) -> Self {
|
||||
self.message = Some(msg.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn data<T: Serialize>(mut self, data: T) -> Self {
|
||||
self.data = Some(json!(data));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn add_error<S: Into<String>>(mut self, error: S) -> Self {
|
||||
self.errors.push(error.into());
|
||||
if self.success {
|
||||
self.success = false;
|
||||
if self.status_code == 200 {
|
||||
self.status_code = 400;
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets a plain text body for the response
|
||||
pub fn body<S: Into<String>>(mut self, body: S) -> Self {
|
||||
self.data = Some(json!(body.into()));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn errors<I, S>(mut self, errors: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<String>,
|
||||
{
|
||||
for error in errors {
|
||||
self = self.add_error(error);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn add_metadata<K, V>(mut self, key: K, value: V) -> Self
|
||||
where
|
||||
K: Into<String>,
|
||||
V: Serialize,
|
||||
{
|
||||
self.metadata.insert(key.into(), json!(value));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn metadata<I, K, V>(mut self, metadata: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = (K, V)>,
|
||||
K: Into<String>,
|
||||
V: Serialize,
|
||||
{
|
||||
for (key, value) in metadata {
|
||||
self = self.add_metadata(key, value);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets JSON data directly for the response
|
||||
pub fn json<T: Serialize>(mut self, data: T) -> Self {
|
||||
self.data = Some(json!(data));
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets HTML content for the response
|
||||
pub fn html<S: Into<String>>(mut self, html: S) -> Self {
|
||||
self.html_body = Some(html.into());
|
||||
self.content_type = Some("text/html".to_string());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets a custom content type for the response
|
||||
pub fn content_type<S: Into<String>>(mut self, content_type: S) -> Self {
|
||||
self.content_type = Some(content_type.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets a canonical error envelope JSON body
|
||||
/// Shape: { "error": { "code": string, "message": string, "details": object } }
|
||||
/// Does not override status; use with helpers like `bad_request()`, `not_found()`, `payment_required()`.
|
||||
pub fn error_envelope<C, M>(mut self, code: C, message: M, details: Value) -> Self
|
||||
where
|
||||
C: Into<String>,
|
||||
M: Into<String>,
|
||||
{
|
||||
// Mark as error semantics
|
||||
self.success = false;
|
||||
// Clear regular data body; we will send raw_json envelope instead
|
||||
self.data = None;
|
||||
// Set canonical error envelope
|
||||
self.raw_json = Some(json!({
|
||||
"error": {
|
||||
"code": code.into(),
|
||||
"message": message.into(),
|
||||
"details": details
|
||||
}
|
||||
}));
|
||||
// If no status set yet, default to 400
|
||||
if self.status_code == 200 {
|
||||
self.status_code = 400;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Builds the final HTTP response
|
||||
pub fn build(self) -> ActixResult<HttpResponse> {
|
||||
// Handle redirect responses specially
|
||||
if self.status_code == 302 {
|
||||
if let Some(location) = self.metadata.get("redirect_location") {
|
||||
if let Some(location_str) = location.as_str() {
|
||||
// Check if we have a cookie to add
|
||||
if let Some(cookie_data) = self.metadata.get("response_cookie") {
|
||||
if let Some(cookie_obj) = cookie_data.as_object() {
|
||||
if let (Some(name), Some(value)) = (
|
||||
cookie_obj.get("name").and_then(|v| v.as_str()),
|
||||
cookie_obj.get("value").and_then(|v| v.as_str())
|
||||
) {
|
||||
let mut cookie_builder = actix_web::cookie::Cookie::build(name, value);
|
||||
if let Some(path) = cookie_obj.get("path").and_then(|v| v.as_str()) {
|
||||
cookie_builder = cookie_builder.path(path);
|
||||
}
|
||||
if let Some(http_only) = cookie_obj.get("http_only").and_then(|v| v.as_bool()) {
|
||||
if http_only {
|
||||
cookie_builder = cookie_builder.http_only(true);
|
||||
}
|
||||
}
|
||||
if let Some(secure) = cookie_obj.get("secure").and_then(|v| v.as_bool()) {
|
||||
if secure {
|
||||
cookie_builder = cookie_builder.secure(true);
|
||||
}
|
||||
}
|
||||
if let Some(max_age_secs) = cookie_obj.get("max_age").and_then(|v| v.as_i64()) {
|
||||
cookie_builder = cookie_builder.max_age(actix_web::cookie::time::Duration::seconds(max_age_secs));
|
||||
}
|
||||
let cookie = cookie_builder.finish();
|
||||
return Ok(HttpResponse::Found()
|
||||
.append_header(("Location", location_str))
|
||||
.cookie(cookie)
|
||||
.finish());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Simple redirect without cookie
|
||||
return Ok(HttpResponse::Found()
|
||||
.append_header(("Location", location_str))
|
||||
.finish());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle HTML responses
|
||||
if let Some(html_content) = self.html_body {
|
||||
let mut http_response = match self.status_code {
|
||||
200 => HttpResponse::Ok(),
|
||||
201 => HttpResponse::Created(),
|
||||
400 => HttpResponse::BadRequest(),
|
||||
401 => HttpResponse::Unauthorized(),
|
||||
403 => HttpResponse::Forbidden(),
|
||||
404 => HttpResponse::NotFound(),
|
||||
500 => HttpResponse::InternalServerError(),
|
||||
_ => HttpResponse::build(actix_web::http::StatusCode::from_u16(self.status_code).unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR)),
|
||||
};
|
||||
|
||||
let content_type = self.content_type.unwrap_or_else(|| "text/html".to_string());
|
||||
return Ok(http_response.content_type(content_type).body(html_content));
|
||||
}
|
||||
|
||||
// Handle raw JSON envelope when provided (e.g., canonical error contract)
|
||||
if let Some(raw) = self.raw_json {
|
||||
let mut http_response = match self.status_code {
|
||||
200 => HttpResponse::Ok(),
|
||||
201 => HttpResponse::Created(),
|
||||
400 => HttpResponse::BadRequest(),
|
||||
401 => HttpResponse::Unauthorized(),
|
||||
402 => HttpResponse::build(actix_web::http::StatusCode::PAYMENT_REQUIRED),
|
||||
403 => HttpResponse::Forbidden(),
|
||||
404 => HttpResponse::NotFound(),
|
||||
500 => HttpResponse::InternalServerError(),
|
||||
_ => HttpResponse::build(actix_web::http::StatusCode::from_u16(self.status_code).unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR)),
|
||||
};
|
||||
return Ok(http_response.json(raw));
|
||||
}
|
||||
|
||||
// Handle regular JSON responses
|
||||
let response = ApiResponse {
|
||||
success: self.success,
|
||||
message: self.message,
|
||||
data: self.data,
|
||||
errors: self.errors,
|
||||
metadata: self.metadata,
|
||||
};
|
||||
|
||||
let mut http_response = match self.status_code {
|
||||
200 => HttpResponse::Ok(),
|
||||
201 => HttpResponse::Created(),
|
||||
400 => HttpResponse::BadRequest(),
|
||||
401 => HttpResponse::Unauthorized(),
|
||||
403 => HttpResponse::Forbidden(),
|
||||
404 => HttpResponse::NotFound(),
|
||||
500 => HttpResponse::InternalServerError(),
|
||||
_ => HttpResponse::build(actix_web::http::StatusCode::from_u16(self.status_code).unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR)),
|
||||
};
|
||||
|
||||
Ok(http_response.json(response))
|
||||
}
|
||||
|
||||
/// Builds a JSON response without HTTP wrapper (for testing)
|
||||
pub fn build_json(self) -> ApiResponse {
|
||||
ApiResponse {
|
||||
success: self.success,
|
||||
message: self.message,
|
||||
data: self.data,
|
||||
errors: self.errors,
|
||||
metadata: self.metadata,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ResponseBuilder {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience macros for common response patterns
|
||||
#[macro_export]
|
||||
macro_rules! success_response {
|
||||
($data:expr) => {
|
||||
$crate::utils::response_builder::ResponseBuilder::success()
|
||||
.data($data)
|
||||
.build()
|
||||
};
|
||||
($data:expr, $message:expr) => {
|
||||
$crate::utils::response_builder::ResponseBuilder::success()
|
||||
.data($data)
|
||||
.message($message)
|
||||
.build()
|
||||
};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! error_response {
|
||||
($message:expr) => {
|
||||
$crate::utils::response_builder::ResponseBuilder::error()
|
||||
.message($message)
|
||||
.build()
|
||||
};
|
||||
($message:expr, $status:expr) => {
|
||||
$crate::utils::response_builder::ResponseBuilder::error()
|
||||
.message($message)
|
||||
.status($status)
|
||||
.build()
|
||||
};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! validation_error_response {
|
||||
($errors:expr) => {
|
||||
$crate::utils::response_builder::ResponseBuilder::error()
|
||||
.message("Validation failed")
|
||||
.errors($errors)
|
||||
.status(422)
|
||||
.build()
|
||||
};
|
||||
}
|
||||
|
||||
/// Template methods for common response patterns
|
||||
impl ResponseBuilder {
|
||||
/// Creates a user creation success response
|
||||
pub fn user_created<T: Serialize>(user: T) -> Self {
|
||||
Self::success()
|
||||
.status(201)
|
||||
.message("User created successfully")
|
||||
.data(user)
|
||||
}
|
||||
|
||||
/// Creates a user updated success response
|
||||
pub fn user_updated<T: Serialize>(user: T) -> Self {
|
||||
Self::success()
|
||||
.message("User updated successfully")
|
||||
.data(user)
|
||||
}
|
||||
|
||||
/// Creates a user deleted success response
|
||||
pub fn user_deleted() -> Self {
|
||||
Self::success()
|
||||
.message("User deleted successfully")
|
||||
}
|
||||
|
||||
/// Creates an authentication failed response
|
||||
pub fn auth_failed() -> Self {
|
||||
Self::unauthorized()
|
||||
.message("Authentication failed")
|
||||
}
|
||||
|
||||
/// Creates a validation failed response
|
||||
pub fn validation_failed<I, S>(errors: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<String>,
|
||||
{
|
||||
Self::error()
|
||||
.status(422)
|
||||
.message("Validation failed")
|
||||
.errors(errors)
|
||||
}
|
||||
|
||||
/// Creates a resource not found response
|
||||
pub fn resource_not_found<S: Into<String>>(resource: S) -> Self {
|
||||
Self::not_found()
|
||||
.message(format!("{} not found", resource.into()))
|
||||
}
|
||||
|
||||
/// Creates a duplicate resource response
|
||||
pub fn duplicate_resource<S: Into<String>>(resource: S) -> Self {
|
||||
Self::error()
|
||||
.status(409)
|
||||
.message(format!("{} already exists", resource.into()))
|
||||
}
|
||||
|
||||
/// Creates a rate limit exceeded response
|
||||
pub fn rate_limit_exceeded() -> Self {
|
||||
Self::error()
|
||||
.status(429)
|
||||
.message("Rate limit exceeded")
|
||||
.add_metadata("retry_after", 60)
|
||||
}
|
||||
|
||||
/// Creates a maintenance mode response
|
||||
pub fn maintenance_mode() -> Self {
|
||||
Self::error()
|
||||
.status(503)
|
||||
.message("Service temporarily unavailable for maintenance")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_success_response() {
|
||||
let response = ResponseBuilder::success()
|
||||
.data(json!({"id": 1, "name": "Test"}))
|
||||
.message("Success")
|
||||
.build_json();
|
||||
|
||||
assert!(response.success);
|
||||
assert_eq!(response.message, Some("Success".to_string()));
|
||||
assert!(response.data.is_some());
|
||||
assert!(response.errors.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_response() {
|
||||
let response = ResponseBuilder::error()
|
||||
.message("Something went wrong")
|
||||
.add_error("Field is required")
|
||||
.build_json();
|
||||
|
||||
assert!(!response.success);
|
||||
assert_eq!(response.message, Some("Something went wrong".to_string()));
|
||||
assert_eq!(response.errors.len(), 1);
|
||||
assert_eq!(response.errors[0], "Field is required");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_paginated_response() {
|
||||
let data = vec![1, 2, 3, 4, 5];
|
||||
let response = ResponseBuilder::paginated(data, 1, 5, 20).build_json();
|
||||
|
||||
assert!(response.success);
|
||||
assert!(response.metadata.contains_key("pagination"));
|
||||
|
||||
let pagination = response.metadata.get("pagination").unwrap();
|
||||
assert_eq!(pagination["page"], 1);
|
||||
assert_eq!(pagination["per_page"], 5);
|
||||
assert_eq!(pagination["total"], 20);
|
||||
assert_eq!(pagination["total_pages"], 4);
|
||||
assert_eq!(pagination["has_next"], true);
|
||||
assert_eq!(pagination["has_prev"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_template_methods() {
|
||||
let user = json!({"id": 1, "name": "John"});
|
||||
let response = ResponseBuilder::user_created(user).build_json();
|
||||
|
||||
assert!(response.success);
|
||||
assert_eq!(response.message, Some("User created successfully".to_string()));
|
||||
assert!(response.data.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validation_failed() {
|
||||
let errors = vec!["Name is required", "Email is invalid"];
|
||||
let response = ResponseBuilder::validation_failed(errors).build_json();
|
||||
|
||||
assert!(!response.success);
|
||||
assert_eq!(response.message, Some("Validation failed".to_string()));
|
||||
assert_eq!(response.errors.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fluent_interface() {
|
||||
let response = ResponseBuilder::new()
|
||||
.status(201)
|
||||
.message("Created")
|
||||
.data(json!({"id": 1}))
|
||||
.add_metadata("version", "1.0")
|
||||
.build_json();
|
||||
|
||||
assert!(response.success);
|
||||
assert_eq!(response.message, Some("Created".to_string()));
|
||||
assert!(response.data.is_some());
|
||||
assert!(response.metadata.contains_key("version"));
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user