feat(api): add unified response format utilities (Section 1, Step 1.1-1.3) #1
@@ -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:
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user