311
web/backend/core/responses.py
Normal file
311
web/backend/core/responses.py
Normal file
@@ -0,0 +1,311 @@
|
||||
"""
|
||||
Standardized Response Format
|
||||
|
||||
Provides consistent response structures for all API endpoints.
|
||||
All responses follow a standardized format for error handling and data delivery.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional, TypeVar, Generic
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# STANDARD RESPONSE MODELS
|
||||
# ============================================================================
|
||||
|
||||
class ErrorDetail(BaseModel):
|
||||
"""Standard error detail structure"""
|
||||
error: str = Field(..., description="Error type/code")
|
||||
message: str = Field(..., description="Human-readable error message")
|
||||
details: Optional[Dict[str, Any]] = Field(None, description="Additional error context")
|
||||
timestamp: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"))
|
||||
|
||||
|
||||
class SuccessResponse(BaseModel):
|
||||
"""Standard success response"""
|
||||
success: bool = True
|
||||
message: Optional[str] = None
|
||||
data: Optional[Any] = None
|
||||
|
||||
|
||||
class PaginatedResponse(BaseModel):
|
||||
"""Standard paginated response"""
|
||||
items: List[Any]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
has_more: bool
|
||||
|
||||
|
||||
class ListResponse(BaseModel):
|
||||
"""Standard list response with metadata"""
|
||||
items: List[Any]
|
||||
count: int
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# RESPONSE BUILDERS
|
||||
# ============================================================================
|
||||
|
||||
def success(data: Any = None, message: str = None) -> Dict[str, Any]:
|
||||
"""Build a success response"""
|
||||
response = {"success": True}
|
||||
if message:
|
||||
response["message"] = message
|
||||
if data is not None:
|
||||
response["data"] = data
|
||||
return response
|
||||
|
||||
|
||||
def error(
|
||||
error_type: str,
|
||||
message: str,
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Build an error response"""
|
||||
return {
|
||||
"success": False,
|
||||
"error": error_type,
|
||||
"message": message,
|
||||
"details": details or {},
|
||||
"timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
}
|
||||
|
||||
|
||||
def paginated(
|
||||
items: List[Any],
|
||||
total: int,
|
||||
page: int,
|
||||
page_size: int
|
||||
) -> Dict[str, Any]:
|
||||
"""Build a paginated response"""
|
||||
return {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"has_more": (page * page_size) < total
|
||||
}
|
||||
|
||||
|
||||
def list_response(items: List[Any], key: str = "items") -> Dict[str, Any]:
|
||||
"""Build a simple list response with custom key"""
|
||||
return {
|
||||
key: items,
|
||||
"count": len(items)
|
||||
}
|
||||
|
||||
|
||||
def message_response(message: str) -> Dict[str, str]:
|
||||
"""Build a simple message response"""
|
||||
return {"message": message}
|
||||
|
||||
|
||||
def count_response(message: str, count: int) -> Dict[str, Any]:
|
||||
"""Build a response with count of affected items"""
|
||||
return {"message": message, "count": count}
|
||||
|
||||
|
||||
def id_response(resource_id: int, message: str = "Resource created successfully") -> Dict[str, Any]:
|
||||
"""Build a response with created resource ID"""
|
||||
return {"id": resource_id, "message": message}
|
||||
|
||||
|
||||
def offset_paginated(
|
||||
items: List[Any],
|
||||
total: int,
|
||||
limit: int,
|
||||
offset: int,
|
||||
key: str = "items",
|
||||
include_timestamp: bool = False,
|
||||
**extra_fields
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Build an offset-based paginated response (common pattern in existing routers).
|
||||
|
||||
This is the standard paginated response format used across all endpoints
|
||||
that return lists of items with pagination support.
|
||||
|
||||
Args:
|
||||
items: Items for current page
|
||||
total: Total count of all items
|
||||
limit: Page size (number of items per page)
|
||||
offset: Current offset (starting position)
|
||||
key: Key name for items list (default "items")
|
||||
include_timestamp: Whether to include timestamp field
|
||||
**extra_fields: Additional fields to include in response
|
||||
|
||||
Returns:
|
||||
Dict with paginated response structure
|
||||
|
||||
Example:
|
||||
return offset_paginated(
|
||||
items=media_items,
|
||||
total=total_count,
|
||||
limit=50,
|
||||
offset=0,
|
||||
key="media",
|
||||
stats={"images": 100, "videos": 50}
|
||||
)
|
||||
"""
|
||||
response = {
|
||||
key: items,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset
|
||||
}
|
||||
|
||||
if include_timestamp:
|
||||
response["timestamp"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
# Add any extra fields
|
||||
response.update(extra_fields)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def batch_operation_response(
|
||||
succeeded: bool,
|
||||
processed: List[Any] = None,
|
||||
errors: List[Dict[str, Any]] = None,
|
||||
message: str = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Build a response for batch operations (batch delete, batch move, etc.).
|
||||
|
||||
This provides a standardized format for operations that affect multiple items.
|
||||
|
||||
Args:
|
||||
succeeded: Overall success status
|
||||
processed: List of successfully processed items
|
||||
errors: List of error details for failed items
|
||||
message: Optional message
|
||||
|
||||
Returns:
|
||||
Dict with batch operation response structure
|
||||
|
||||
Example:
|
||||
return batch_operation_response(
|
||||
succeeded=True,
|
||||
processed=["/path/to/file1.jpg", "/path/to/file2.jpg"],
|
||||
errors=[{"file": "/path/to/file3.jpg", "error": "File not found"}]
|
||||
)
|
||||
"""
|
||||
processed = processed or []
|
||||
errors = errors or []
|
||||
|
||||
response = {
|
||||
"success": succeeded,
|
||||
"processed_count": len(processed),
|
||||
"error_count": len(errors),
|
||||
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
}
|
||||
|
||||
if processed:
|
||||
response["processed"] = processed
|
||||
|
||||
if errors:
|
||||
response["errors"] = errors
|
||||
|
||||
if message:
|
||||
response["message"] = message
|
||||
|
||||
return response
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# DATE/TIME UTILITIES
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def to_iso8601(dt: datetime) -> Optional[str]:
|
||||
"""
|
||||
Convert datetime to ISO 8601 format with UTC timezone.
|
||||
This is the standard format for all API responses.
|
||||
|
||||
Example output: "2025-12-04T10:30:00Z"
|
||||
"""
|
||||
if dt is None:
|
||||
return None
|
||||
|
||||
# Ensure timezone awareness
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
dt = dt.astimezone(timezone.utc)
|
||||
|
||||
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def from_iso8601(date_string: str) -> Optional[datetime]:
|
||||
"""
|
||||
Parse ISO 8601 date string to datetime.
|
||||
Handles various formats including with and without timezone.
|
||||
"""
|
||||
if not date_string:
|
||||
return None
|
||||
|
||||
# Common formats to try
|
||||
formats = [
|
||||
"%Y-%m-%dT%H:%M:%SZ", # ISO with Z
|
||||
"%Y-%m-%dT%H:%M:%S.%fZ", # ISO with microseconds and Z
|
||||
"%Y-%m-%dT%H:%M:%S", # ISO without Z
|
||||
"%Y-%m-%dT%H:%M:%S.%f", # ISO with microseconds
|
||||
"%Y-%m-%d %H:%M:%S", # Space separator
|
||||
"%Y-%m-%d", # Date only
|
||||
]
|
||||
|
||||
for fmt in formats:
|
||||
try:
|
||||
dt = datetime.strptime(date_string, fmt)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# Try parsing with fromisoformat (Python 3.7+)
|
||||
try:
|
||||
dt = datetime.fromisoformat(date_string.replace('Z', '+00:00'))
|
||||
return dt
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def format_timestamp(
|
||||
timestamp: Optional[str],
|
||||
output_format: str = "iso8601"
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Format a timestamp string to the specified format.
|
||||
|
||||
Args:
|
||||
timestamp: Input timestamp string
|
||||
output_format: "iso8601", "unix", or strftime format string
|
||||
|
||||
Returns:
|
||||
Formatted timestamp string
|
||||
"""
|
||||
if not timestamp:
|
||||
return None
|
||||
|
||||
dt = from_iso8601(timestamp)
|
||||
if not dt:
|
||||
return timestamp # Return original if parsing failed
|
||||
|
||||
if output_format == "iso8601":
|
||||
return to_iso8601(dt)
|
||||
elif output_format == "unix":
|
||||
return str(int(dt.timestamp()))
|
||||
else:
|
||||
return dt.strftime(output_format)
|
||||
|
||||
|
||||
def now_iso8601() -> str:
|
||||
"""Get current UTC time in ISO 8601 format"""
|
||||
return to_iso8601(datetime.now(timezone.utc))
|
||||
Reference in New Issue
Block a user