first commit
This commit is contained in:
1
api/app/utils/__init__.py
Normal file
1
api/app/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Utility modules for Code of Conquest."""
|
||||
444
api/app/utils/auth.py
Normal file
444
api/app/utils/auth.py
Normal file
@@ -0,0 +1,444 @@
|
||||
"""
|
||||
Authentication Utilities
|
||||
|
||||
This module provides authentication middleware, decorators, and helper functions
|
||||
for protecting routes and managing user sessions.
|
||||
|
||||
Usage:
|
||||
from app.utils.auth import require_auth, require_tier, get_current_user
|
||||
|
||||
@app.route('/protected')
|
||||
@require_auth
|
||||
def protected_route():
|
||||
user = get_current_user()
|
||||
return f"Hello, {user.name}!"
|
||||
|
||||
@app.route('/premium-feature')
|
||||
@require_auth
|
||||
@require_tier('premium')
|
||||
def premium_feature():
|
||||
return "Premium content"
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
from typing import Optional, Callable
|
||||
from flask import request, g, jsonify, redirect, url_for
|
||||
|
||||
from app.services.appwrite_service import AppwriteService, UserData
|
||||
from app.utils.response import unauthorized_response, forbidden_response
|
||||
from app.utils.logging import get_logger
|
||||
from app.config import get_config
|
||||
from appwrite.exception import AppwriteException
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
def extract_session_token() -> Optional[str]:
|
||||
"""
|
||||
Extract the session token from the request cookie.
|
||||
|
||||
Returns:
|
||||
Session token string if found, None otherwise
|
||||
"""
|
||||
config = get_config()
|
||||
cookie_name = config.auth.cookie_name
|
||||
|
||||
token = request.cookies.get(cookie_name)
|
||||
return token
|
||||
|
||||
|
||||
def verify_session(token: str) -> Optional[UserData]:
|
||||
"""
|
||||
Verify a session token and return the associated user data.
|
||||
|
||||
This function:
|
||||
1. Validates the session token with Appwrite
|
||||
2. Checks if the session is still active (not expired)
|
||||
3. Retrieves and returns the user data
|
||||
|
||||
Args:
|
||||
token: Session token from cookie
|
||||
|
||||
Returns:
|
||||
UserData object if session is valid, None otherwise
|
||||
"""
|
||||
try:
|
||||
appwrite = AppwriteService()
|
||||
|
||||
# Validate session
|
||||
session_data = appwrite.get_session(session_id=token)
|
||||
|
||||
# Get user data
|
||||
user_data = appwrite.get_user(user_id=session_data.user_id)
|
||||
return user_data
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.warning("Session verification failed", error=str(e), code=e.code)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error during session verification", error=str(e))
|
||||
return None
|
||||
|
||||
|
||||
def get_current_user() -> Optional[UserData]:
|
||||
"""
|
||||
Get the current authenticated user from the request context.
|
||||
|
||||
This function retrieves the user object that was attached to the
|
||||
request context by the @require_auth decorator.
|
||||
|
||||
Returns:
|
||||
UserData object if user is authenticated, None otherwise
|
||||
|
||||
Usage:
|
||||
@app.route('/profile')
|
||||
@require_auth
|
||||
def profile():
|
||||
user = get_current_user()
|
||||
return f"Welcome, {user.name}!"
|
||||
"""
|
||||
return getattr(g, 'current_user', None)
|
||||
|
||||
|
||||
def require_auth(f: Callable) -> Callable:
|
||||
"""
|
||||
Decorator to require authentication for a route (API endpoints).
|
||||
|
||||
This decorator:
|
||||
1. Extracts the session token from the cookie
|
||||
2. Verifies the session with Appwrite
|
||||
3. Attaches the user object to the request context (g.current_user)
|
||||
4. Allows the request to proceed if authenticated
|
||||
5. Returns 401 Unauthorized JSON if not authenticated
|
||||
|
||||
For web views, use @require_auth_web instead.
|
||||
|
||||
Args:
|
||||
f: The Flask route function to wrap
|
||||
|
||||
Returns:
|
||||
Wrapped function with authentication check
|
||||
|
||||
Usage:
|
||||
@app.route('/api/protected')
|
||||
@require_auth
|
||||
def protected_route():
|
||||
user = get_current_user()
|
||||
return f"Hello, {user.name}!"
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# Extract session token from cookie
|
||||
token = extract_session_token()
|
||||
|
||||
if not token:
|
||||
logger.warning("Authentication required but no session token provided", path=request.path)
|
||||
return unauthorized_response(message="Authentication required. Please log in.")
|
||||
|
||||
# Verify session and get user
|
||||
user = verify_session(token)
|
||||
|
||||
if not user:
|
||||
logger.warning("Invalid or expired session token", path=request.path)
|
||||
return unauthorized_response(message="Session invalid or expired. Please log in again.")
|
||||
|
||||
# Attach user to request context
|
||||
g.current_user = user
|
||||
|
||||
logger.debug("User authenticated", user_id=user.id, email=user.email, path=request.path)
|
||||
|
||||
# Call the original function
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def require_auth_web(f: Callable) -> Callable:
|
||||
"""
|
||||
Decorator to require authentication for a web view route.
|
||||
|
||||
This decorator:
|
||||
1. Extracts the session token from the cookie
|
||||
2. Verifies the session with Appwrite
|
||||
3. Attaches the user object to the request context (g.current_user)
|
||||
4. Allows the request to proceed if authenticated
|
||||
5. Redirects to login page if not authenticated
|
||||
|
||||
For API endpoints, use @require_auth instead.
|
||||
|
||||
Args:
|
||||
f: The Flask route function to wrap
|
||||
|
||||
Returns:
|
||||
Wrapped function with authentication check
|
||||
|
||||
Usage:
|
||||
@app.route('/dashboard')
|
||||
@require_auth_web
|
||||
def dashboard():
|
||||
user = get_current_user()
|
||||
return render_template('dashboard.html', user=user)
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# Extract session token from cookie
|
||||
token = extract_session_token()
|
||||
|
||||
if not token:
|
||||
logger.warning("Authentication required but no session token provided", path=request.path)
|
||||
return redirect(url_for('auth_views.login'))
|
||||
|
||||
# Verify session and get user
|
||||
user = verify_session(token)
|
||||
|
||||
if not user:
|
||||
logger.warning("Invalid or expired session token", path=request.path)
|
||||
return redirect(url_for('auth_views.login'))
|
||||
|
||||
# Attach user to request context
|
||||
g.current_user = user
|
||||
|
||||
logger.debug("User authenticated", user_id=user.id, email=user.email, path=request.path)
|
||||
|
||||
# Call the original function
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def require_tier(minimum_tier: str) -> Callable:
|
||||
"""
|
||||
Decorator to require a minimum subscription tier for a route.
|
||||
|
||||
This decorator must be used AFTER @require_auth.
|
||||
|
||||
Tier hierarchy (from lowest to highest):
|
||||
- free
|
||||
- basic
|
||||
- premium
|
||||
- elite
|
||||
|
||||
Args:
|
||||
minimum_tier: Minimum required tier (free, basic, premium, elite)
|
||||
|
||||
Returns:
|
||||
Decorator function
|
||||
|
||||
Raises:
|
||||
ValueError: If minimum_tier is invalid
|
||||
|
||||
Usage:
|
||||
@app.route('/premium-feature')
|
||||
@require_auth
|
||||
@require_tier('premium')
|
||||
def premium_feature():
|
||||
return "Premium content"
|
||||
"""
|
||||
# Define tier hierarchy
|
||||
tier_hierarchy = {
|
||||
'free': 0,
|
||||
'basic': 1,
|
||||
'premium': 2,
|
||||
'elite': 3
|
||||
}
|
||||
|
||||
if minimum_tier not in tier_hierarchy:
|
||||
raise ValueError(f"Invalid tier: {minimum_tier}. Must be one of {list(tier_hierarchy.keys())}")
|
||||
|
||||
def decorator(f: Callable) -> Callable:
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# Get current user (set by @require_auth)
|
||||
user = get_current_user()
|
||||
|
||||
if not user:
|
||||
logger.error("require_tier used without require_auth", path=request.path)
|
||||
return unauthorized_response(message="Authentication required.")
|
||||
|
||||
# Get user's tier level
|
||||
user_tier = user.tier
|
||||
user_tier_level = tier_hierarchy.get(user_tier, 0)
|
||||
required_tier_level = tier_hierarchy[minimum_tier]
|
||||
|
||||
# Check if user has sufficient tier
|
||||
if user_tier_level < required_tier_level:
|
||||
logger.warning(
|
||||
"Access denied - insufficient tier",
|
||||
user_id=user.id,
|
||||
user_tier=user_tier,
|
||||
required_tier=minimum_tier,
|
||||
path=request.path
|
||||
)
|
||||
return forbidden_response(
|
||||
message=f"This feature requires {minimum_tier.capitalize()} tier or higher. "
|
||||
f"Your current tier: {user_tier.capitalize()}."
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Tier requirement met",
|
||||
user_id=user.id,
|
||||
user_tier=user_tier,
|
||||
required_tier=minimum_tier,
|
||||
path=request.path
|
||||
)
|
||||
|
||||
# Call the original function
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def require_email_verified(f: Callable) -> Callable:
|
||||
"""
|
||||
Decorator to require email verification for a route.
|
||||
|
||||
This decorator must be used AFTER @require_auth.
|
||||
|
||||
Args:
|
||||
f: The Flask route function to wrap
|
||||
|
||||
Returns:
|
||||
Wrapped function with email verification check
|
||||
|
||||
Usage:
|
||||
@app.route('/verified-only')
|
||||
@require_auth
|
||||
@require_email_verified
|
||||
def verified_only():
|
||||
return "You can only see this if your email is verified"
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# Get current user (set by @require_auth)
|
||||
user = get_current_user()
|
||||
|
||||
if not user:
|
||||
logger.error("require_email_verified used without require_auth", path=request.path)
|
||||
return unauthorized_response(message="Authentication required.")
|
||||
|
||||
# Check if email is verified
|
||||
if not user.email_verified:
|
||||
logger.warning(
|
||||
"Access denied - email not verified",
|
||||
user_id=user.id,
|
||||
email=user.email,
|
||||
path=request.path
|
||||
)
|
||||
return forbidden_response(
|
||||
message="Email verification required. Please check your inbox and verify your email address."
|
||||
)
|
||||
|
||||
logger.debug("Email verification confirmed", user_id=user.id, email=user.email, path=request.path)
|
||||
|
||||
# Call the original function
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def optional_auth(f: Callable) -> Callable:
|
||||
"""
|
||||
Decorator for routes that optionally use authentication.
|
||||
|
||||
This decorator will attach the user to g.current_user if authenticated,
|
||||
but will NOT block the request if not authenticated. Use this for routes
|
||||
that should behave differently based on authentication status.
|
||||
|
||||
Args:
|
||||
f: The Flask route function to wrap
|
||||
|
||||
Returns:
|
||||
Wrapped function with optional authentication
|
||||
|
||||
Usage:
|
||||
@app.route('/landing')
|
||||
@optional_auth
|
||||
def landing():
|
||||
user = get_current_user()
|
||||
if user:
|
||||
return f"Welcome back, {user.name}!"
|
||||
else:
|
||||
return "Welcome! Please log in."
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# Extract session token from cookie
|
||||
token = extract_session_token()
|
||||
|
||||
if token:
|
||||
# Verify session and get user
|
||||
user = verify_session(token)
|
||||
|
||||
if user:
|
||||
# Attach user to request context
|
||||
g.current_user = user
|
||||
logger.debug("Optional auth - user authenticated", user_id=user.id, path=request.path)
|
||||
else:
|
||||
logger.debug("Optional auth - invalid session", path=request.path)
|
||||
else:
|
||||
logger.debug("Optional auth - no session token", path=request.path)
|
||||
|
||||
# Call the original function regardless of authentication
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def get_user_tier() -> str:
|
||||
"""
|
||||
Get the current user's tier.
|
||||
|
||||
Returns:
|
||||
Tier string (free, basic, premium, elite), defaults to 'free' if not authenticated
|
||||
|
||||
Usage:
|
||||
@app.route('/dashboard')
|
||||
@require_auth
|
||||
def dashboard():
|
||||
tier = get_user_tier()
|
||||
return f"Your tier: {tier}"
|
||||
"""
|
||||
user = get_current_user()
|
||||
if user:
|
||||
return user.tier
|
||||
return 'free'
|
||||
|
||||
|
||||
def is_tier_sufficient(required_tier: str) -> bool:
|
||||
"""
|
||||
Check if the current user's tier meets the requirement.
|
||||
|
||||
Args:
|
||||
required_tier: Required tier level
|
||||
|
||||
Returns:
|
||||
True if user's tier is sufficient, False otherwise
|
||||
|
||||
Usage:
|
||||
@app.route('/feature')
|
||||
@require_auth
|
||||
def feature():
|
||||
if is_tier_sufficient('premium'):
|
||||
return "Premium features enabled"
|
||||
else:
|
||||
return "Upgrade to premium for more features"
|
||||
"""
|
||||
tier_hierarchy = {
|
||||
'free': 0,
|
||||
'basic': 1,
|
||||
'premium': 2,
|
||||
'elite': 3
|
||||
}
|
||||
|
||||
user = get_current_user()
|
||||
if not user:
|
||||
return False
|
||||
|
||||
user_tier_level = tier_hierarchy.get(user.tier, 0)
|
||||
required_tier_level = tier_hierarchy.get(required_tier, 0)
|
||||
|
||||
return user_tier_level >= required_tier_level
|
||||
272
api/app/utils/logging.py
Normal file
272
api/app/utils/logging.py
Normal file
@@ -0,0 +1,272 @@
|
||||
"""
|
||||
Logging configuration for Code of Conquest.
|
||||
|
||||
Sets up structured logging using structlog with JSON output
|
||||
and context-aware logging throughout the application.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
from structlog.stdlib import LoggerFactory
|
||||
|
||||
|
||||
def setup_logging(
|
||||
log_level: str = "INFO",
|
||||
log_format: str = "json",
|
||||
log_file: Optional[str] = None
|
||||
) -> None:
|
||||
"""
|
||||
Configure structured logging for the application.
|
||||
|
||||
Args:
|
||||
log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
log_format: Output format ('json' or 'console')
|
||||
log_file: Optional path to log file
|
||||
|
||||
Example:
|
||||
>>> from app.utils.logging import setup_logging
|
||||
>>> setup_logging(log_level="DEBUG", log_format="json")
|
||||
>>> logger = structlog.get_logger(__name__)
|
||||
>>> logger.info("Application started", version="0.1.0")
|
||||
"""
|
||||
# Convert log level string to logging constant
|
||||
numeric_level = getattr(logging, log_level.upper(), logging.INFO)
|
||||
|
||||
# Configure standard library logging
|
||||
logging.basicConfig(
|
||||
format="%(message)s",
|
||||
stream=sys.stdout,
|
||||
level=numeric_level,
|
||||
)
|
||||
|
||||
# Create logs directory if logging to file
|
||||
if log_file:
|
||||
log_path = Path(log_file)
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Add file handler
|
||||
file_handler = logging.FileHandler(log_file)
|
||||
file_handler.setLevel(numeric_level)
|
||||
logging.root.addHandler(file_handler)
|
||||
|
||||
# Configure structlog processors
|
||||
processors = [
|
||||
# Add log level to event dict
|
||||
structlog.stdlib.add_log_level,
|
||||
# Add logger name to event dict
|
||||
structlog.stdlib.add_logger_name,
|
||||
# Add timestamp
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
# Add stack info for exceptions
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
# Format exceptions
|
||||
structlog.processors.format_exc_info,
|
||||
# Clean up _record and _from_structlog keys
|
||||
structlog.processors.UnicodeDecoder(),
|
||||
]
|
||||
|
||||
# Add format-specific processor
|
||||
if log_format == "json":
|
||||
# JSON output for production
|
||||
processors.append(structlog.processors.JSONRenderer())
|
||||
else:
|
||||
# Console-friendly output for development
|
||||
processors.append(
|
||||
structlog.dev.ConsoleRenderer(
|
||||
colors=True,
|
||||
exception_formatter=structlog.dev.plain_traceback
|
||||
)
|
||||
)
|
||||
|
||||
# Configure structlog
|
||||
structlog.configure(
|
||||
processors=processors,
|
||||
context_class=dict,
|
||||
logger_factory=LoggerFactory(),
|
||||
wrapper_class=structlog.stdlib.BoundLogger,
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
|
||||
|
||||
def get_logger(name: str) -> structlog.stdlib.BoundLogger:
|
||||
"""
|
||||
Get a configured logger instance.
|
||||
|
||||
Args:
|
||||
name: Logger name (typically __name__)
|
||||
|
||||
Returns:
|
||||
BoundLogger: Configured structlog logger
|
||||
|
||||
Example:
|
||||
>>> logger = get_logger(__name__)
|
||||
>>> logger.info("User logged in", user_id="123", email="user@example.com")
|
||||
"""
|
||||
return structlog.get_logger(name)
|
||||
|
||||
|
||||
class LoggerMixin:
|
||||
"""
|
||||
Mixin class to add logging capabilities to any class.
|
||||
|
||||
Provides a `self.logger` attribute with context automatically
|
||||
bound to the class name.
|
||||
|
||||
Example:
|
||||
>>> class MyService(LoggerMixin):
|
||||
... def do_something(self, user_id):
|
||||
... self.logger.info("Doing something", user_id=user_id)
|
||||
"""
|
||||
|
||||
@property
|
||||
def logger(self) -> structlog.stdlib.BoundLogger:
|
||||
"""Get logger for this class."""
|
||||
if not hasattr(self, '_logger'):
|
||||
self._logger = get_logger(self.__class__.__name__)
|
||||
return self._logger
|
||||
|
||||
|
||||
# Common logging utilities
|
||||
|
||||
def log_function_call(logger: structlog.stdlib.BoundLogger):
|
||||
"""
|
||||
Decorator to log function calls with arguments and return values.
|
||||
|
||||
Args:
|
||||
logger: Logger instance to use
|
||||
|
||||
Example:
|
||||
>>> logger = get_logger(__name__)
|
||||
>>> @log_function_call(logger)
|
||||
... def process_data(data_id):
|
||||
... return {"status": "processed"}
|
||||
"""
|
||||
def decorator(func):
|
||||
def wrapper(*args, **kwargs):
|
||||
logger.debug(
|
||||
f"Calling {func.__name__}",
|
||||
function=func.__name__,
|
||||
args=args,
|
||||
kwargs=kwargs
|
||||
)
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
logger.debug(
|
||||
f"{func.__name__} completed",
|
||||
function=func.__name__,
|
||||
result=result
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"{func.__name__} failed",
|
||||
function=func.__name__,
|
||||
error=str(e),
|
||||
exc_info=True
|
||||
)
|
||||
raise
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def log_ai_call(
|
||||
logger: structlog.stdlib.BoundLogger,
|
||||
user_id: str,
|
||||
model: str,
|
||||
tier: str,
|
||||
tokens_used: int,
|
||||
cost_estimate: float,
|
||||
context_type: str
|
||||
) -> None:
|
||||
"""
|
||||
Log AI API call for cost tracking and analytics.
|
||||
|
||||
Args:
|
||||
logger: Logger instance
|
||||
user_id: User making the request
|
||||
model: AI model used
|
||||
tier: Model tier (free, standard, premium)
|
||||
tokens_used: Number of tokens consumed
|
||||
cost_estimate: Estimated cost in USD
|
||||
context_type: Type of context (narrative, combat, etc.)
|
||||
"""
|
||||
logger.info(
|
||||
"AI call completed",
|
||||
event_type="ai_call",
|
||||
user_id=user_id,
|
||||
model=model,
|
||||
tier=tier,
|
||||
tokens_used=tokens_used,
|
||||
cost_estimate=cost_estimate,
|
||||
context_type=context_type
|
||||
)
|
||||
|
||||
|
||||
def log_combat_action(
|
||||
logger: structlog.stdlib.BoundLogger,
|
||||
session_id: str,
|
||||
character_id: str,
|
||||
action_type: str,
|
||||
target_id: Optional[str] = None,
|
||||
damage: Optional[int] = None,
|
||||
effects: Optional[list] = None
|
||||
) -> None:
|
||||
"""
|
||||
Log combat action for analytics and debugging.
|
||||
|
||||
Args:
|
||||
logger: Logger instance
|
||||
session_id: Game session ID
|
||||
character_id: Acting character ID
|
||||
action_type: Type of action (attack, cast, item, defend)
|
||||
target_id: Target of action (if applicable)
|
||||
damage: Damage dealt (if applicable)
|
||||
effects: Effects applied (if applicable)
|
||||
"""
|
||||
logger.info(
|
||||
"Combat action executed",
|
||||
event_type="combat_action",
|
||||
session_id=session_id,
|
||||
character_id=character_id,
|
||||
action_type=action_type,
|
||||
target_id=target_id,
|
||||
damage=damage,
|
||||
effects=effects
|
||||
)
|
||||
|
||||
|
||||
def log_marketplace_transaction(
|
||||
logger: structlog.stdlib.BoundLogger,
|
||||
transaction_id: str,
|
||||
buyer_id: str,
|
||||
seller_id: str,
|
||||
item_id: str,
|
||||
price: int,
|
||||
transaction_type: str
|
||||
) -> None:
|
||||
"""
|
||||
Log marketplace transaction for analytics and auditing.
|
||||
|
||||
Args:
|
||||
logger: Logger instance
|
||||
transaction_id: Transaction ID
|
||||
buyer_id: Buyer user ID
|
||||
seller_id: Seller user ID
|
||||
item_id: Item ID
|
||||
price: Transaction price
|
||||
transaction_type: Type of transaction
|
||||
"""
|
||||
logger.info(
|
||||
"Marketplace transaction",
|
||||
event_type="marketplace_transaction",
|
||||
transaction_id=transaction_id,
|
||||
buyer_id=buyer_id,
|
||||
seller_id=seller_id,
|
||||
item_id=item_id,
|
||||
price=price,
|
||||
transaction_type=transaction_type
|
||||
)
|
||||
337
api/app/utils/response.py
Normal file
337
api/app/utils/response.py
Normal file
@@ -0,0 +1,337 @@
|
||||
"""
|
||||
API response wrapper for Code of Conquest.
|
||||
|
||||
Provides standardized JSON response format for all API endpoints.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
from flask import jsonify, Response
|
||||
from app.config import get_config
|
||||
|
||||
|
||||
def api_response(
|
||||
result: Any = None,
|
||||
status: int = 200,
|
||||
error: Optional[Dict[str, Any]] = None,
|
||||
meta: Optional[Dict[str, Any]] = None,
|
||||
request_id: Optional[str] = None
|
||||
) -> Response:
|
||||
"""
|
||||
Create a standardized API response.
|
||||
|
||||
Args:
|
||||
result: The response data (or None if error)
|
||||
status: HTTP status code
|
||||
error: Error information (dict with 'code', 'message', 'details')
|
||||
meta: Metadata (pagination, etc.)
|
||||
request_id: Optional request ID for tracking
|
||||
|
||||
Returns:
|
||||
Response: Flask JSON response
|
||||
|
||||
Example:
|
||||
>>> return api_response(
|
||||
... result={"user_id": "123"},
|
||||
... status=200
|
||||
... )
|
||||
|
||||
>>> return api_response(
|
||||
... error={
|
||||
... "code": "INVALID_INPUT",
|
||||
... "message": "Email is required",
|
||||
... "details": {"field": "email"}
|
||||
... },
|
||||
... status=400
|
||||
... )
|
||||
"""
|
||||
config = get_config()
|
||||
|
||||
response_data = {
|
||||
"app": config.app.name,
|
||||
"version": config.app.version,
|
||||
"status": status,
|
||||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||
"request_id": request_id,
|
||||
"result": result,
|
||||
"error": error,
|
||||
"meta": meta
|
||||
}
|
||||
|
||||
return jsonify(response_data), status
|
||||
|
||||
|
||||
def success_response(
|
||||
result: Any = None,
|
||||
status: int = 200,
|
||||
meta: Optional[Dict[str, Any]] = None,
|
||||
request_id: Optional[str] = None
|
||||
) -> Response:
|
||||
"""
|
||||
Create a success response.
|
||||
|
||||
Args:
|
||||
result: The response data
|
||||
status: HTTP status code (default 200)
|
||||
meta: Optional metadata
|
||||
request_id: Optional request ID
|
||||
|
||||
Returns:
|
||||
Response: Flask JSON response
|
||||
|
||||
Example:
|
||||
>>> return success_response({"character_id": "123"})
|
||||
"""
|
||||
return api_response(
|
||||
result=result,
|
||||
status=status,
|
||||
meta=meta,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
|
||||
def error_response(
|
||||
code: str,
|
||||
message: str,
|
||||
status: int = 400,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
request_id: Optional[str] = None
|
||||
) -> Response:
|
||||
"""
|
||||
Create an error response.
|
||||
|
||||
Args:
|
||||
code: Error code (e.g., "INVALID_INPUT")
|
||||
message: Human-readable error message
|
||||
status: HTTP status code (default 400)
|
||||
details: Optional additional error details
|
||||
request_id: Optional request ID
|
||||
|
||||
Returns:
|
||||
Response: Flask JSON response
|
||||
|
||||
Example:
|
||||
>>> return error_response(
|
||||
... code="NOT_FOUND",
|
||||
... message="Character not found",
|
||||
... status=404
|
||||
... )
|
||||
"""
|
||||
error = {
|
||||
"code": code,
|
||||
"message": message,
|
||||
"details": details or {}
|
||||
}
|
||||
|
||||
return api_response(
|
||||
error=error,
|
||||
status=status,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
|
||||
def created_response(
|
||||
result: Any = None,
|
||||
request_id: Optional[str] = None
|
||||
) -> Response:
|
||||
"""
|
||||
Create a 201 Created response.
|
||||
|
||||
Args:
|
||||
result: The created resource data
|
||||
request_id: Optional request ID
|
||||
|
||||
Returns:
|
||||
Response: Flask JSON response with status 201
|
||||
|
||||
Example:
|
||||
>>> return created_response({"character_id": "123"})
|
||||
"""
|
||||
return success_response(
|
||||
result=result,
|
||||
status=201,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
|
||||
def accepted_response(
|
||||
result: Any = None,
|
||||
request_id: Optional[str] = None
|
||||
) -> Response:
|
||||
"""
|
||||
Create a 202 Accepted response (for async operations).
|
||||
|
||||
Args:
|
||||
result: Job information or status
|
||||
request_id: Optional request ID
|
||||
|
||||
Returns:
|
||||
Response: Flask JSON response with status 202
|
||||
|
||||
Example:
|
||||
>>> return accepted_response({"job_id": "abc123"})
|
||||
"""
|
||||
return success_response(
|
||||
result=result,
|
||||
status=202,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
|
||||
def no_content_response(request_id: Optional[str] = None) -> Response:
|
||||
"""
|
||||
Create a 204 No Content response.
|
||||
|
||||
Args:
|
||||
request_id: Optional request ID
|
||||
|
||||
Returns:
|
||||
Response: Flask JSON response with status 204
|
||||
|
||||
Example:
|
||||
>>> return no_content_response()
|
||||
"""
|
||||
return success_response(
|
||||
result=None,
|
||||
status=204,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
|
||||
def paginated_response(
|
||||
items: list,
|
||||
page: int,
|
||||
limit: int,
|
||||
total: int,
|
||||
request_id: Optional[str] = None
|
||||
) -> Response:
|
||||
"""
|
||||
Create a paginated response.
|
||||
|
||||
Args:
|
||||
items: List of items for current page
|
||||
page: Current page number
|
||||
limit: Items per page
|
||||
total: Total number of items
|
||||
request_id: Optional request ID
|
||||
|
||||
Returns:
|
||||
Response: Flask JSON response with pagination metadata
|
||||
|
||||
Example:
|
||||
>>> return paginated_response(
|
||||
... items=[{"id": "1"}, {"id": "2"}],
|
||||
... page=1,
|
||||
... limit=20,
|
||||
... total=100
|
||||
... )
|
||||
"""
|
||||
pages = (total + limit - 1) // limit # Ceiling division
|
||||
|
||||
meta = {
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
"pages": pages
|
||||
}
|
||||
|
||||
return success_response(
|
||||
result=items,
|
||||
meta=meta,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
|
||||
# Common error responses
|
||||
|
||||
def unauthorized_response(
|
||||
message: str = "Unauthorized",
|
||||
request_id: Optional[str] = None
|
||||
) -> Response:
|
||||
"""401 Unauthorized response."""
|
||||
return error_response(
|
||||
code="UNAUTHORIZED",
|
||||
message=message,
|
||||
status=401,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
|
||||
def forbidden_response(
|
||||
message: str = "Forbidden",
|
||||
request_id: Optional[str] = None
|
||||
) -> Response:
|
||||
"""403 Forbidden response."""
|
||||
return error_response(
|
||||
code="FORBIDDEN",
|
||||
message=message,
|
||||
status=403,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
|
||||
def not_found_response(
|
||||
message: str = "Resource not found",
|
||||
request_id: Optional[str] = None
|
||||
) -> Response:
|
||||
"""404 Not Found response."""
|
||||
return error_response(
|
||||
code="NOT_FOUND",
|
||||
message=message,
|
||||
status=404,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
|
||||
def validation_error_response(
|
||||
message: str,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
request_id: Optional[str] = None
|
||||
) -> Response:
|
||||
"""400 Bad Request for validation errors."""
|
||||
return error_response(
|
||||
code="INVALID_INPUT",
|
||||
message=message,
|
||||
status=400,
|
||||
details=details,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
|
||||
def rate_limit_exceeded_response(
|
||||
message: str = "Rate limit exceeded",
|
||||
request_id: Optional[str] = None
|
||||
) -> Response:
|
||||
"""429 Too Many Requests response."""
|
||||
return error_response(
|
||||
code="RATE_LIMIT_EXCEEDED",
|
||||
message=message,
|
||||
status=429,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
|
||||
def internal_error_response(
|
||||
message: str = "Internal server error",
|
||||
request_id: Optional[str] = None
|
||||
) -> Response:
|
||||
"""500 Internal Server Error response."""
|
||||
return error_response(
|
||||
code="INTERNAL_ERROR",
|
||||
message=message,
|
||||
status=500,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
|
||||
def premium_required_response(
|
||||
message: str = "This feature requires a premium subscription",
|
||||
request_id: Optional[str] = None
|
||||
) -> Response:
|
||||
"""403 Forbidden for premium-only features."""
|
||||
return error_response(
|
||||
code="PREMIUM_REQUIRED",
|
||||
message=message,
|
||||
status=403,
|
||||
request_id=request_id
|
||||
)
|
||||
Reference in New Issue
Block a user