694 lines
22 KiB
Rust
694 lines
22 KiB
Rust
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"));
|
|
}
|
|
}
|