init projectmycelium

This commit is contained in:
mik-tf
2025-09-01 21:37:01 -04:00
commit b41efb0e99
319 changed files with 128160 additions and 0 deletions

235
src/utils/data_cleanup.rs Normal file
View 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
View 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
View 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))
}

View 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"));
}
}