feat(api): add unified response format utilities (Section 1, Step 1.1-1.3) #1

Closed
salman wants to merge 11 commits from feature/api-unified-response-format into main
2 changed files with 129 additions and 93 deletions
Showing only changes of commit d14d6093e0 - Show all commits

View File

@@ -56,16 +56,9 @@ def extract_error_message(exc, response):
if isinstance(exc.detail, dict): if isinstance(exc.detail, dict):
# Validation errors - use as errors dict # Validation errors - use as errors dict
errors = exc.detail errors = exc.detail
# Extract first error message as top-level error # Set a general validation error message
if errors: # The specific field errors are in the errors dict
first_key = list(errors.keys())[0] error_message = "Validation failed"
first_error = errors[first_key]
if isinstance(first_error, list) and first_error:
error_message = f"{first_key}: {first_error[0]}"
else:
error_message = f"{first_key}: {first_error}"
else:
error_message = "Validation failed"
elif isinstance(exc.detail, list): elif isinstance(exc.detail, list):
# List of errors # List of errors
error_message = exc.detail[0] if exc.detail else "An error occurred" error_message = exc.detail[0] if exc.detail else "An error occurred"
@@ -76,14 +69,35 @@ def extract_error_message(exc, response):
elif response and hasattr(response, 'data'): elif response and hasattr(response, 'data'):
# Try to extract from response data # Try to extract from response data
if isinstance(response.data, dict): if isinstance(response.data, dict):
# Check for common error message fields # If response already has unified format, preserve it
error_message = ( if 'success' in response.data:
response.data.get('error') or error_message = response.data.get('error', 'An error occurred')
response.data.get('message') or errors = response.data.get('errors')
response.data.get('detail') or return error_message, errors
str(response.data)
) # If response.data looks like validation errors (dict with field names as keys)
errors = response.data if 'error' not in response.data else None # and no 'error', 'message', or 'detail' fields, treat it as validation errors
if 'error' not in response.data and 'message' not in response.data and 'detail' not in response.data:
# This is a validation error dict - extract errors and set error message
errors = response.data
error_message = 'Validation failed'
else:
# Check for common error message fields
error_message = (
response.data.get('error') or
response.data.get('message') or
response.data.get('detail') or
'Validation failed' if response.data else 'An error occurred'
)
# Extract errors from response.data if it's a dict with field errors
# Check if response.data contains field names (likely validation errors)
if isinstance(response.data, dict) and any(
isinstance(v, (list, str)) for v in response.data.values()
) and 'error' not in response.data and 'message' not in response.data:
errors = response.data
error_message = 'Validation failed'
else:
errors = response.data if 'error' not in response.data else None
elif isinstance(response.data, list): elif isinstance(response.data, list):
error_message = response.data[0] if response.data else "An error occurred" error_message = response.data[0] if response.data else "An error occurred"
errors = {"non_field_errors": response.data} errors = {"non_field_errors": response.data}
@@ -148,6 +162,15 @@ def custom_exception_handler(exc, context):
# Determine status code # Determine status code
if response is not None: if response is not None:
status_code = response.status_code status_code = response.status_code
# If response already has unified format (success field), return it as-is
# This handles cases where error_response() was manually returned
if hasattr(response, 'data') and isinstance(response.data, dict):
if 'success' in response.data:
# Response already in unified format - just add request_id if needed
if request_id and 'request_id' not in response.data:
response.data['request_id'] = request_id
return response
else: else:
# Unhandled exception - default to 500 # Unhandled exception - default to 500
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
@@ -188,8 +211,13 @@ def custom_exception_handler(exc, context):
} }
# Add errors dict if present # Add errors dict if present
# Always include errors if we have them, even if error_message was set
if errors: if errors:
error_response_data["errors"] = errors error_response_data["errors"] = errors
# If we have errors but no error message was set, ensure we have one
elif not error_message or error_message == "An error occurred":
error_message = get_status_code_message(status_code)
error_response_data["error"] = error_message
# Add request ID for error tracking # Add request ID for error tracking
if request_id: if request_id:

View File

@@ -11,6 +11,7 @@ from django.db import transaction
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from igny8_core.api.base import AccountModelViewSet from igny8_core.api.base import AccountModelViewSet
from igny8_core.api.authentication import JWTAuthentication, CSRFExemptSessionAuthentication from igny8_core.api.authentication import JWTAuthentication, CSRFExemptSessionAuthentication
from igny8_core.api.response import success_response, error_response
from .models import User, Account, Plan, Subscription, Site, Sector, SiteUserAccess, Industry, IndustrySector, SeedKeyword from .models import User, Account, Plan, Subscription, Site, Sector, SiteUserAccess, Industry, IndustrySector, SeedKeyword
from .serializers import ( from .serializers import (
UserSerializer, AccountSerializer, PlanSerializer, SubscriptionSerializer, UserSerializer, AccountSerializer, PlanSerializer, SubscriptionSerializer,
@@ -680,21 +681,24 @@ class AuthViewSet(viewsets.GenericViewSet):
refresh_expires_at = get_token_expiry('refresh') refresh_expires_at = get_token_expiry('refresh')
user_serializer = UserSerializer(user) user_serializer = UserSerializer(user)
return Response({ return success_response(
'success': True, data={
'message': 'Registration successful', 'user': user_serializer.data,
'user': user_serializer.data, 'tokens': {
'tokens': { 'access': access_token,
'access': access_token, 'refresh': refresh_token,
'refresh': refresh_token, 'access_expires_at': access_expires_at.isoformat(),
'access_expires_at': access_expires_at.isoformat(), 'refresh_expires_at': refresh_expires_at.isoformat(),
'refresh_expires_at': refresh_expires_at.isoformat(), }
} },
}, status=status.HTTP_201_CREATED) message='Registration successful',
return Response({ status_code=status.HTTP_201_CREATED
'success': False, )
'errors': serializer.errors return error_response(
}, status=status.HTTP_400_BAD_REQUEST) error='Validation failed',
errors=serializer.errors,
status_code=status.HTTP_400_BAD_REQUEST
)
@action(detail=False, methods=['post']) @action(detail=False, methods=['post'])
def login(self, request): def login(self, request):
@@ -707,10 +711,10 @@ class AuthViewSet(viewsets.GenericViewSet):
try: try:
user = User.objects.select_related('account', 'account__plan').get(email=email) user = User.objects.select_related('account', 'account__plan').get(email=email)
except User.DoesNotExist: except User.DoesNotExist:
return Response({ return error_response(
'success': False, error='Invalid credentials',
'message': 'Invalid credentials' status_code=status.HTTP_401_UNAUTHORIZED
}, status=status.HTTP_401_UNAUTHORIZED) )
if user.check_password(password): if user.check_password(password):
# Log the user in (create session for session authentication) # Log the user in (create session for session authentication)
@@ -727,27 +731,29 @@ class AuthViewSet(viewsets.GenericViewSet):
refresh_expires_at = get_token_expiry('refresh') refresh_expires_at = get_token_expiry('refresh')
user_serializer = UserSerializer(user) user_serializer = UserSerializer(user)
return Response({ return success_response(
'success': True, data={
'message': 'Login successful', 'user': user_serializer.data,
'user': user_serializer.data, 'tokens': {
'tokens': { 'access': access_token,
'access': access_token, 'refresh': refresh_token,
'refresh': refresh_token, 'access_expires_at': access_expires_at.isoformat(),
'access_expires_at': access_expires_at.isoformat(), 'refresh_expires_at': refresh_expires_at.isoformat(),
'refresh_expires_at': refresh_expires_at.isoformat(), }
} },
}) message='Login successful'
)
return Response({ return error_response(
'success': False, error='Invalid credentials',
'message': 'Invalid credentials' status_code=status.HTTP_401_UNAUTHORIZED
}, status=status.HTTP_401_UNAUTHORIZED) )
return Response({ return error_response(
'success': False, error='Validation failed',
'errors': serializer.errors errors=serializer.errors,
}, status=status.HTTP_400_BAD_REQUEST) status_code=status.HTTP_400_BAD_REQUEST
)
@action(detail=False, methods=['post'], permission_classes=[permissions.IsAuthenticated]) @action(detail=False, methods=['post'], permission_classes=[permissions.IsAuthenticated])
def change_password(self, request): def change_password(self, request):
@@ -756,23 +762,23 @@ class AuthViewSet(viewsets.GenericViewSet):
if serializer.is_valid(): if serializer.is_valid():
user = request.user user = request.user
if not user.check_password(serializer.validated_data['old_password']): if not user.check_password(serializer.validated_data['old_password']):
return Response({ return error_response(
'success': False, error='Current password is incorrect',
'message': 'Current password is incorrect' status_code=status.HTTP_400_BAD_REQUEST
}, status=status.HTTP_400_BAD_REQUEST) )
user.set_password(serializer.validated_data['new_password']) user.set_password(serializer.validated_data['new_password'])
user.save() user.save()
return Response({ return success_response(
'success': True, message='Password changed successfully'
'message': 'Password changed successfully' )
})
return Response({ return error_response(
'success': False, error='Validation failed',
'errors': serializer.errors errors=serializer.errors,
}, status=status.HTTP_400_BAD_REQUEST) status_code=status.HTTP_400_BAD_REQUEST
)
@action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated]) @action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated])
def me(self, request): def me(self, request):
@@ -781,20 +787,20 @@ class AuthViewSet(viewsets.GenericViewSet):
# This ensures account/plan changes are reflected immediately # This ensures account/plan changes are reflected immediately
user = User.objects.select_related('account', 'account__plan').get(id=request.user.id) user = User.objects.select_related('account', 'account__plan').get(id=request.user.id)
serializer = UserSerializer(user) serializer = UserSerializer(user)
return Response({ return success_response(
'success': True, data={'user': serializer.data}
'user': serializer.data )
})
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny]) @action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
def refresh(self, request): def refresh(self, request):
"""Refresh access token using refresh token.""" """Refresh access token using refresh token."""
serializer = RefreshTokenSerializer(data=request.data) serializer = RefreshTokenSerializer(data=request.data)
if not serializer.is_valid(): if not serializer.is_valid():
return Response({ return error_response(
'success': False, error='Validation failed',
'errors': serializer.errors errors=serializer.errors,
}, status=status.HTTP_400_BAD_REQUEST) status_code=status.HTTP_400_BAD_REQUEST
)
refresh_token = serializer.validated_data['refresh'] refresh_token = serializer.validated_data['refresh']
@@ -804,10 +810,10 @@ class AuthViewSet(viewsets.GenericViewSet):
# Verify it's a refresh token # Verify it's a refresh token
if payload.get('type') != 'refresh': if payload.get('type') != 'refresh':
return Response({ return error_response(
'success': False, error='Invalid token type',
'message': 'Invalid token type' status_code=status.HTTP_400_BAD_REQUEST
}, status=status.HTTP_400_BAD_REQUEST) )
# Get user # Get user
user_id = payload.get('user_id') user_id = payload.get('user_id')
@@ -816,10 +822,10 @@ class AuthViewSet(viewsets.GenericViewSet):
try: try:
user = User.objects.get(id=user_id) user = User.objects.get(id=user_id)
except User.DoesNotExist: except User.DoesNotExist:
return Response({ return error_response(
'success': False, error='User not found',
'message': 'User not found' status_code=status.HTTP_404_NOT_FOUND
}, status=status.HTTP_404_NOT_FOUND) )
# Get account # Get account
account_id = payload.get('account_id') account_id = payload.get('account_id')
@@ -837,17 +843,19 @@ class AuthViewSet(viewsets.GenericViewSet):
access_token = generate_access_token(user, account) access_token = generate_access_token(user, account)
access_expires_at = get_token_expiry('access') access_expires_at = get_token_expiry('access')
return Response({ return success_response(
'success': True, data={
'access': access_token, 'access': access_token,
'access_expires_at': access_expires_at.isoformat() 'access_expires_at': access_expires_at.isoformat()
}) },
message='Token refreshed successfully'
)
except jwt.InvalidTokenError as e: except jwt.InvalidTokenError as e:
return Response({ return error_response(
'success': False, error='Invalid or expired refresh token',
'message': 'Invalid or expired refresh token' status_code=status.HTTP_401_UNAUTHORIZED
}, status=status.HTTP_401_UNAUTHORIZED) )
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny]) @action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
def request_reset(self, request): def request_reset(self, request):