feat(api): add unified response format utilities (Section 1, Step 1.1-1.3)
- Create response.py with success_response(), error_response(), paginated_response() - Add unit tests for all response utility functions - Create /api/ping/ health check endpoint using new format - Update __init__.py to export response functions - All changes are non-breaking - existing code unaffected This implements Section 1 Task 1 from API-IMPLEMENTATION-PLAN-SECTION1.md Ready for testing before applying to existing endpoints. Ref: unified-api/API-IMPLEMENTATION-PLAN-SECTION1.md
This commit is contained in:
@@ -2,3 +2,7 @@
|
|||||||
IGNY8 API Module
|
IGNY8 API Module
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from .response import success_response, error_response, paginated_response
|
||||||
|
|
||||||
|
__all__ = ['success_response', 'error_response', 'paginated_response']
|
||||||
|
|
||||||
|
|||||||
111
backend/igny8_core/api/response.py
Normal file
111
backend/igny8_core/api/response.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"""
|
||||||
|
Unified API Response Format Utilities
|
||||||
|
|
||||||
|
This module provides helper functions to ensure all API endpoints
|
||||||
|
return a consistent response format.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework import status
|
||||||
|
|
||||||
|
|
||||||
|
def success_response(data=None, message=None, status_code=status.HTTP_200_OK):
|
||||||
|
"""
|
||||||
|
Create a standardized success response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Response data (dict, list, or any serializable object)
|
||||||
|
message: Optional human-readable success message
|
||||||
|
status_code: HTTP status code (default: 200)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response: DRF Response object with unified format
|
||||||
|
|
||||||
|
Example:
|
||||||
|
return success_response(
|
||||||
|
data={"id": 1, "name": "Example"},
|
||||||
|
message="Resource created successfully"
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
response_data = {
|
||||||
|
"success": True,
|
||||||
|
"data": data,
|
||||||
|
}
|
||||||
|
|
||||||
|
if message:
|
||||||
|
response_data["message"] = message
|
||||||
|
|
||||||
|
return Response(response_data, status=status_code)
|
||||||
|
|
||||||
|
|
||||||
|
def error_response(
|
||||||
|
error,
|
||||||
|
errors=None,
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
message=None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Create a standardized error response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
error: Top-level error message (string)
|
||||||
|
errors: Optional field-specific validation errors (dict)
|
||||||
|
status_code: HTTP status code (default: 400)
|
||||||
|
message: Optional additional message (deprecated, use error)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response: DRF Response object with unified format
|
||||||
|
|
||||||
|
Example:
|
||||||
|
return error_response(
|
||||||
|
error="Validation failed",
|
||||||
|
errors={"email": ["Invalid email format"]},
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
response_data = {
|
||||||
|
"success": False,
|
||||||
|
"error": error,
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
response_data["errors"] = errors
|
||||||
|
|
||||||
|
# Backward compatibility: if message is provided, use it as error
|
||||||
|
if message and not error:
|
||||||
|
response_data["error"] = message
|
||||||
|
|
||||||
|
return Response(response_data, status=status_code)
|
||||||
|
|
||||||
|
|
||||||
|
def paginated_response(paginated_data, message=None):
|
||||||
|
"""
|
||||||
|
Create a standardized paginated response.
|
||||||
|
|
||||||
|
This wraps DRF's pagination response to include success flag
|
||||||
|
and optional message while preserving pagination metadata.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
paginated_data: DRF paginated response data (dict with count, next, previous, results)
|
||||||
|
message: Optional human-readable message
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response: DRF Response object with unified format
|
||||||
|
|
||||||
|
Example:
|
||||||
|
paginator = CustomPageNumberPagination()
|
||||||
|
page = paginator.paginate_queryset(queryset, request)
|
||||||
|
serializer = MySerializer(page, many=True)
|
||||||
|
paginated_data = paginator.get_paginated_response(serializer.data).data
|
||||||
|
return paginated_response(paginated_data, message="Keywords retrieved successfully")
|
||||||
|
"""
|
||||||
|
response_data = {
|
||||||
|
"success": True,
|
||||||
|
**paginated_data # Unpack count, next, previous, results
|
||||||
|
}
|
||||||
|
|
||||||
|
if message:
|
||||||
|
response_data["message"] = message
|
||||||
|
|
||||||
|
return Response(response_data)
|
||||||
|
|
||||||
4
backend/igny8_core/api/tests/__init__.py
Normal file
4
backend/igny8_core/api/tests/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"""
|
||||||
|
API Tests Module
|
||||||
|
"""
|
||||||
|
|
||||||
198
backend/igny8_core/api/tests/test_response.py
Normal file
198
backend/igny8_core/api/tests/test_response.py
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
"""
|
||||||
|
Unit Tests for Response Utility Functions
|
||||||
|
|
||||||
|
Tests all response wrapper functions to ensure they return
|
||||||
|
the correct unified format.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
from rest_framework import status
|
||||||
|
from igny8_core.api.response import success_response, error_response, paginated_response
|
||||||
|
|
||||||
|
|
||||||
|
class SuccessResponseTestCase(TestCase):
|
||||||
|
"""Test cases for success_response function"""
|
||||||
|
|
||||||
|
def test_success_response_with_data_only(self):
|
||||||
|
"""Test success_response with data only"""
|
||||||
|
data = {"id": 1, "name": "Test"}
|
||||||
|
response = success_response(data=data)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
response_data = response.data
|
||||||
|
self.assertTrue(response_data['success'])
|
||||||
|
self.assertEqual(response_data['data'], data)
|
||||||
|
self.assertNotIn('message', response_data)
|
||||||
|
|
||||||
|
def test_success_response_with_data_and_message(self):
|
||||||
|
"""Test success_response with data and message"""
|
||||||
|
data = {"id": 1, "name": "Test"}
|
||||||
|
message = "Resource created successfully"
|
||||||
|
response = success_response(data=data, message=message)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
response_data = response.data
|
||||||
|
self.assertTrue(response_data['success'])
|
||||||
|
self.assertEqual(response_data['data'], data)
|
||||||
|
self.assertEqual(response_data['message'], message)
|
||||||
|
|
||||||
|
def test_success_response_with_custom_status_code_201(self):
|
||||||
|
"""Test success_response with 201 Created status"""
|
||||||
|
data = {"id": 1, "name": "Test"}
|
||||||
|
response = success_response(data=data, status_code=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
response_data = response.data
|
||||||
|
self.assertTrue(response_data['success'])
|
||||||
|
self.assertEqual(response_data['data'], data)
|
||||||
|
|
||||||
|
def test_success_response_with_custom_status_code_204(self):
|
||||||
|
"""Test success_response with 204 No Content status"""
|
||||||
|
response = success_response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
|
||||||
|
response_data = response.data
|
||||||
|
self.assertTrue(response_data['success'])
|
||||||
|
|
||||||
|
def test_success_response_with_list_data(self):
|
||||||
|
"""Test success_response with list data"""
|
||||||
|
data = [{"id": 1}, {"id": 2}, {"id": 3}]
|
||||||
|
response = success_response(data=data)
|
||||||
|
|
||||||
|
response_data = response.data
|
||||||
|
self.assertTrue(response_data['success'])
|
||||||
|
self.assertEqual(response_data['data'], data)
|
||||||
|
self.assertIsInstance(response_data['data'], list)
|
||||||
|
|
||||||
|
def test_success_response_with_none_data(self):
|
||||||
|
"""Test success_response with None data"""
|
||||||
|
response = success_response(data=None)
|
||||||
|
|
||||||
|
response_data = response.data
|
||||||
|
self.assertTrue(response_data['success'])
|
||||||
|
self.assertIsNone(response_data['data'])
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorResponseTestCase(TestCase):
|
||||||
|
"""Test cases for error_response function"""
|
||||||
|
|
||||||
|
def test_error_response_with_error_only(self):
|
||||||
|
"""Test error_response with error message only"""
|
||||||
|
error_msg = "Something went wrong"
|
||||||
|
response = error_response(error=error_msg)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
response_data = response.data
|
||||||
|
self.assertFalse(response_data['success'])
|
||||||
|
self.assertEqual(response_data['error'], error_msg)
|
||||||
|
self.assertNotIn('errors', response_data)
|
||||||
|
|
||||||
|
def test_error_response_with_error_and_errors_dict(self):
|
||||||
|
"""Test error_response with error and field-specific errors"""
|
||||||
|
error_msg = "Validation failed"
|
||||||
|
errors = {
|
||||||
|
"email": ["Invalid email format"],
|
||||||
|
"password": ["Password must be at least 8 characters"]
|
||||||
|
}
|
||||||
|
response = error_response(error=error_msg, errors=errors)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
response_data = response.data
|
||||||
|
self.assertFalse(response_data['success'])
|
||||||
|
self.assertEqual(response_data['error'], error_msg)
|
||||||
|
self.assertEqual(response_data['errors'], errors)
|
||||||
|
|
||||||
|
def test_error_response_with_custom_status_code_403(self):
|
||||||
|
"""Test error_response with 403 Forbidden status"""
|
||||||
|
error_msg = "Permission denied"
|
||||||
|
response = error_response(error=error_msg, status_code=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
response_data = response.data
|
||||||
|
self.assertFalse(response_data['success'])
|
||||||
|
self.assertEqual(response_data['error'], error_msg)
|
||||||
|
|
||||||
|
def test_error_response_with_custom_status_code_404(self):
|
||||||
|
"""Test error_response with 404 Not Found status"""
|
||||||
|
error_msg = "Resource not found"
|
||||||
|
response = error_response(error=error_msg, status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||||
|
response_data = response.data
|
||||||
|
self.assertFalse(response_data['success'])
|
||||||
|
self.assertEqual(response_data['error'], error_msg)
|
||||||
|
|
||||||
|
def test_error_response_with_custom_status_code_500(self):
|
||||||
|
"""Test error_response with 500 Internal Server Error status"""
|
||||||
|
error_msg = "Internal server error"
|
||||||
|
response = error_response(error=error_msg, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
response_data = response.data
|
||||||
|
self.assertFalse(response_data['success'])
|
||||||
|
self.assertEqual(response_data['error'], error_msg)
|
||||||
|
|
||||||
|
def test_error_response_backward_compatibility_with_message(self):
|
||||||
|
"""Test error_response backward compatibility with message parameter"""
|
||||||
|
message = "Old error message"
|
||||||
|
response = error_response(error=None, message=message)
|
||||||
|
|
||||||
|
response_data = response.data
|
||||||
|
self.assertFalse(response_data['success'])
|
||||||
|
self.assertEqual(response_data['error'], message)
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatedResponseTestCase(TestCase):
|
||||||
|
"""Test cases for paginated_response function"""
|
||||||
|
|
||||||
|
def test_paginated_response_with_standard_data(self):
|
||||||
|
"""Test paginated_response with standard pagination data"""
|
||||||
|
paginated_data = {
|
||||||
|
"count": 100,
|
||||||
|
"next": "http://api.example.com/endpoint/?page=2",
|
||||||
|
"previous": None,
|
||||||
|
"results": [{"id": 1}, {"id": 2}]
|
||||||
|
}
|
||||||
|
response = paginated_response(paginated_data)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
response_data = response.data
|
||||||
|
self.assertTrue(response_data['success'])
|
||||||
|
self.assertEqual(response_data['count'], 100)
|
||||||
|
self.assertEqual(response_data['next'], paginated_data['next'])
|
||||||
|
self.assertIsNone(response_data['previous'])
|
||||||
|
self.assertEqual(response_data['results'], paginated_data['results'])
|
||||||
|
|
||||||
|
def test_paginated_response_with_message(self):
|
||||||
|
"""Test paginated_response with optional message"""
|
||||||
|
paginated_data = {
|
||||||
|
"count": 50,
|
||||||
|
"next": None,
|
||||||
|
"previous": None,
|
||||||
|
"results": [{"id": 1}]
|
||||||
|
}
|
||||||
|
message = "Keywords retrieved successfully"
|
||||||
|
response = paginated_response(paginated_data, message=message)
|
||||||
|
|
||||||
|
response_data = response.data
|
||||||
|
self.assertTrue(response_data['success'])
|
||||||
|
self.assertEqual(response_data['message'], message)
|
||||||
|
self.assertEqual(response_data['count'], 50)
|
||||||
|
self.assertEqual(response_data['results'], paginated_data['results'])
|
||||||
|
|
||||||
|
def test_paginated_response_without_message(self):
|
||||||
|
"""Test paginated_response without message"""
|
||||||
|
paginated_data = {
|
||||||
|
"count": 25,
|
||||||
|
"next": "http://api.example.com/endpoint/?page=3",
|
||||||
|
"previous": "http://api.example.com/endpoint/?page=1",
|
||||||
|
"results": []
|
||||||
|
}
|
||||||
|
response = paginated_response(paginated_data)
|
||||||
|
|
||||||
|
response_data = response.data
|
||||||
|
self.assertTrue(response_data['success'])
|
||||||
|
self.assertNotIn('message', response_data)
|
||||||
|
self.assertEqual(response_data['count'], 25)
|
||||||
|
self.assertEqual(len(response_data['results']), 0)
|
||||||
|
|
||||||
33
backend/igny8_core/api/views.py
Normal file
33
backend/igny8_core/api/views.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""
|
||||||
|
API Views for IGNY8 Core
|
||||||
|
|
||||||
|
Health check and utility endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from django.utils import timezone
|
||||||
|
from igny8_core.api.response import success_response
|
||||||
|
|
||||||
|
|
||||||
|
class PingView(APIView):
|
||||||
|
"""
|
||||||
|
Health check endpoint for API availability.
|
||||||
|
|
||||||
|
Returns simple pong response to verify API is live.
|
||||||
|
This endpoint uses the new unified response format.
|
||||||
|
"""
|
||||||
|
permission_classes = [] # Public endpoint - no authentication required
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
"""
|
||||||
|
Return health check response in unified format.
|
||||||
|
"""
|
||||||
|
return success_response(
|
||||||
|
data={
|
||||||
|
"pong": True,
|
||||||
|
"time": timezone.now().isoformat(),
|
||||||
|
"version": "1.0.0"
|
||||||
|
},
|
||||||
|
message="API is live"
|
||||||
|
)
|
||||||
|
|
||||||
@@ -16,9 +16,11 @@ Including another URLconf
|
|||||||
"""
|
"""
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
|
from igny8_core.api.views import PingView
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
|
path('api/ping/', PingView.as_view(), name='ping'), # Health check endpoint
|
||||||
path('api/v1/auth/', include('igny8_core.auth.urls')), # Auth endpoints
|
path('api/v1/auth/', include('igny8_core.auth.urls')), # Auth endpoints
|
||||||
path('api/v1/planner/', include('igny8_core.modules.planner.urls')),
|
path('api/v1/planner/', include('igny8_core.modules.planner.urls')),
|
||||||
path('api/v1/writer/', include('igny8_core.modules.writer.urls')),
|
path('api/v1/writer/', include('igny8_core.modules.writer.urls')),
|
||||||
|
|||||||
Reference in New Issue
Block a user