From a722f6caa3ec0b61892f809934057cef7dc360e6 Mon Sep 17 00:00:00 2001 From: Desktop Date: Fri, 14 Nov 2025 20:05:22 +0500 Subject: [PATCH] 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 --- backend/igny8_core/api/__init__.py | 4 + backend/igny8_core/api/response.py | 111 ++++++++++ backend/igny8_core/api/tests/__init__.py | 4 + backend/igny8_core/api/tests/test_response.py | 198 ++++++++++++++++++ backend/igny8_core/api/views.py | 33 +++ backend/igny8_core/urls.py | 2 + 6 files changed, 352 insertions(+) create mode 100644 backend/igny8_core/api/response.py create mode 100644 backend/igny8_core/api/tests/__init__.py create mode 100644 backend/igny8_core/api/tests/test_response.py create mode 100644 backend/igny8_core/api/views.py diff --git a/backend/igny8_core/api/__init__.py b/backend/igny8_core/api/__init__.py index 9322e85d..2cbd0659 100644 --- a/backend/igny8_core/api/__init__.py +++ b/backend/igny8_core/api/__init__.py @@ -2,3 +2,7 @@ IGNY8 API Module """ +from .response import success_response, error_response, paginated_response + +__all__ = ['success_response', 'error_response', 'paginated_response'] + diff --git a/backend/igny8_core/api/response.py b/backend/igny8_core/api/response.py new file mode 100644 index 00000000..02d497b4 --- /dev/null +++ b/backend/igny8_core/api/response.py @@ -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) + diff --git a/backend/igny8_core/api/tests/__init__.py b/backend/igny8_core/api/tests/__init__.py new file mode 100644 index 00000000..bd74c7de --- /dev/null +++ b/backend/igny8_core/api/tests/__init__.py @@ -0,0 +1,4 @@ +""" +API Tests Module +""" + diff --git a/backend/igny8_core/api/tests/test_response.py b/backend/igny8_core/api/tests/test_response.py new file mode 100644 index 00000000..49efb87c --- /dev/null +++ b/backend/igny8_core/api/tests/test_response.py @@ -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) + diff --git a/backend/igny8_core/api/views.py b/backend/igny8_core/api/views.py new file mode 100644 index 00000000..fb483e1e --- /dev/null +++ b/backend/igny8_core/api/views.py @@ -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" + ) + diff --git a/backend/igny8_core/urls.py b/backend/igny8_core/urls.py index 031fb2f4..60f33cef 100644 --- a/backend/igny8_core/urls.py +++ b/backend/igny8_core/urls.py @@ -16,9 +16,11 @@ Including another URLconf """ from django.contrib import admin from django.urls import path, include +from igny8_core.api.views import PingView urlpatterns = [ 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/planner/', include('igny8_core.modules.planner.urls')), path('api/v1/writer/', include('igny8_core.modules.writer.urls')),