Files
media-downloader/web/backend/core/responses.py
Todd 0d7b2b1aab Initial commit
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 22:42:55 -04:00

312 lines
8.3 KiB
Python

"""
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))