diff --git a/README.md b/README.md index fc66e94..f5c4c5e 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ ``` python manage.py runserver ``` -10. Go to http://127.0.0.1:8000/health/ API to make sure the server it up. You should see this response +10. Go to http://127.0.0.1:8000/v1/health API to make sure the server it up. You should see this response ``` { "status": "UP", @@ -64,7 +64,7 @@ ``` docker-compose up -d ``` -3. Go to http://127.0.0.1:8000/health/ API to make sure the server it up. You should see this response +3. Go to http://127.0.0.1:8000/v1/health API to make sure the server it up. You should see this response ``` { "status": "UP" diff --git a/todo/constants/task.py b/todo/constants/task.py index 0752fe2..81c56a2 100644 --- a/todo/constants/task.py +++ b/todo/constants/task.py @@ -1,5 +1,8 @@ from enum import Enum +DEFAULT_PAGE_LIMIT = 20 +MAX_PAGE_LIMIT = 200 + class TaskStatus(Enum): TODO = "TODO" diff --git a/todo/dto/responses/error_response.py b/todo/dto/responses/error_response.py new file mode 100644 index 0000000..4126b98 --- /dev/null +++ b/todo/dto/responses/error_response.py @@ -0,0 +1,21 @@ +from enum import Enum +from typing import Dict, List +from pydantic import BaseModel + + +class ApiErrorSource(Enum): + PARAMETER = "parameter" + POINTER = "pointer" + HEADER = "header" + + +class ApiErrorDetail(BaseModel): + source: Dict[ApiErrorSource, str] | None = None + title: str | None = None + detail: str | None = None + + +class ApiErrorResponse(BaseModel): + statusCode: int + message: str + errors: List[ApiErrorDetail] diff --git a/todo/exceptions/exception_handler.py b/todo/exceptions/exception_handler.py new file mode 100644 index 0000000..397ef6c --- /dev/null +++ b/todo/exceptions/exception_handler.py @@ -0,0 +1,36 @@ +from typing import List +from rest_framework.exceptions import ValidationError +from rest_framework.response import Response +from rest_framework import status +from rest_framework.views import exception_handler +from rest_framework.utils.serializer_helpers import ReturnDict + +from todo.dto.responses.error_response import ApiErrorDetail, ApiErrorResponse, ApiErrorSource + + +def handle_exception(exc, context): + if isinstance(exc, ValidationError): + return Response( + ApiErrorResponse( + statusCode=status.HTTP_400_BAD_REQUEST, + message="Invalid request", + errors=format_validation_errors(exc.detail), + ).model_dump(mode="json", exclude_none=True), + status=status.HTTP_400_BAD_REQUEST, + ) + return exception_handler(exc, context) + + +def format_validation_errors(errors) -> List[ApiErrorDetail]: + formatted_errors = [] + if isinstance(errors, ReturnDict | dict): + for field, messages in errors.items(): + if isinstance(messages, list): + for message in messages: + formatted_errors.append(ApiErrorDetail(detail=message, source={ApiErrorSource.PARAMETER: field})) + elif isinstance(messages, dict): + nested_errors = format_validation_errors(messages) + formatted_errors.extend(nested_errors) + else: + formatted_errors.append(ApiErrorDetail(detail=messages, source={ApiErrorSource.PARAMETER: field})) + return formatted_errors diff --git a/todo/serializers/get_tasks_serializer.py b/todo/serializers/get_tasks_serializer.py new file mode 100644 index 0000000..4d2232a --- /dev/null +++ b/todo/serializers/get_tasks_serializer.py @@ -0,0 +1,23 @@ +from rest_framework import serializers + +from todo.constants.task import DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT + + +class GetTaskQueryParamsSerializer(serializers.Serializer): + page = serializers.IntegerField( + required=False, + default=1, + min_value=1, + error_messages={ + "min_value": "page must be greater than or equal to 1", + }, + ) + limit = serializers.IntegerField( + required=False, + default=DEFAULT_PAGE_LIMIT, + min_value=1, + max_value=MAX_PAGE_LIMIT, + error_messages={ + "min_value": "limit must be greater than or equal to 1", + }, + ) diff --git a/todo/tests/unit/exceptions/__init__.py b/todo/tests/unit/exceptions/__init__.py new file mode 100644 index 0000000..84a4d93 --- /dev/null +++ b/todo/tests/unit/exceptions/__init__.py @@ -0,0 +1 @@ +# Added this because without this file Django isn't able to auto detect the test files diff --git a/todo/tests/unit/exceptions/test_exception_handler.py b/todo/tests/unit/exceptions/test_exception_handler.py new file mode 100644 index 0000000..80dcb71 --- /dev/null +++ b/todo/tests/unit/exceptions/test_exception_handler.py @@ -0,0 +1,73 @@ +from unittest import TestCase +from unittest.mock import Mock, patch +from rest_framework.exceptions import ValidationError +from rest_framework.response import Response +from rest_framework import status + +from todo.exceptions.exception_handler import handle_exception, format_validation_errors +from todo.dto.responses.error_response import ApiErrorDetail, ApiErrorSource + + +class ExceptionHandlerTests(TestCase): + @patch("todo.exceptions.exception_handler.format_validation_errors") + def test_returns_400_for_validation_error(self, mock_format_validation_errors: Mock): + validation_error = ValidationError(detail={"field": ["error message"]}) + mock_format_validation_errors.return_value = [ + ApiErrorDetail(detail="error message", source={ApiErrorSource.PARAMETER: "field"}) + ] + + response = handle_exception(validation_error, {}) + + self.assertIsInstance(response, Response) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + expected_response = { + "statusCode": 400, + "message": "Invalid request", + "errors": [{"source": {"parameter": "field"}, "detail": "error message"}], + } + self.assertDictEqual(response.data, expected_response) + + mock_format_validation_errors.assert_called_once_with(validation_error.detail) + + def test_uses_default_handler_for_non_validation_error(self): + generic_exception = ValueError("Something went wrong") + + response = handle_exception(generic_exception, {}) + self.assertIsNone(response) + + +class FormatValidationErrorsTests(TestCase): + def test_formats_flat_validation_errors(self): + errors = {"field": ["error message 1", "error message 2"]} + expected_result = [ + ApiErrorDetail(detail="error message 1", source={ApiErrorSource.PARAMETER: "field"}), + ApiErrorDetail(detail="error message 2", source={ApiErrorSource.PARAMETER: "field"}), + ] + + result = format_validation_errors(errors) + + self.assertEqual(result, expected_result) + + def test_formats_nested_validation_errors(self): + errors = { + "parent_field": { + "child_field": ["child error message"], + "another_child": {"deep_field": ["deep error message"]}, + } + } + expected_result = [ + ApiErrorDetail(detail="child error message", source={ApiErrorSource.PARAMETER: "child_field"}), + ApiErrorDetail(detail="deep error message", source={ApiErrorSource.PARAMETER: "deep_field"}), + ] + + result = format_validation_errors(errors) + + self.assertEqual(result, expected_result) + + def test_formats_non_list_dict_validation_error(self): + errors = {"field": "Not a list or dict"} + expected_result = [ApiErrorDetail(detail="Not a list or dict", source={ApiErrorSource.PARAMETER: "field"})] + + result = format_validation_errors(errors) + + self.assertEqual(result, expected_result) diff --git a/todo/tests/unit/serializers/__init__.py b/todo/tests/unit/serializers/__init__.py new file mode 100644 index 0000000..84a4d93 --- /dev/null +++ b/todo/tests/unit/serializers/__init__.py @@ -0,0 +1 @@ +# Added this because without this file Django isn't able to auto detect the test files diff --git a/todo/tests/unit/serializers/test_get_tasks_serializer.py b/todo/tests/unit/serializers/test_get_tasks_serializer.py new file mode 100644 index 0000000..3abfa8c --- /dev/null +++ b/todo/tests/unit/serializers/test_get_tasks_serializer.py @@ -0,0 +1,56 @@ +from unittest import TestCase +from rest_framework.exceptions import ValidationError + +from todo.constants.task import DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT +from todo.serializers.get_tasks_serializer import GetTaskQueryParamsSerializer + + +class GetTaskQueryParamsSerializerTest(TestCase): + def test_serializer_validates_and_returns_valid_input(self): + data = {"page": "2", "limit": "5"} + serializer = GetTaskQueryParamsSerializer(data=data) + self.assertTrue(serializer.is_valid()) + self.assertEqual(serializer.validated_data["page"], 2) + self.assertEqual(serializer.validated_data["limit"], 5) + + def test_serializer_applies_default_values_for_missing_fields(self): + serializer = GetTaskQueryParamsSerializer(data={}) + self.assertTrue(serializer.is_valid()) + self.assertEqual(serializer.validated_data["page"], 1) + self.assertEqual(serializer.validated_data["limit"], DEFAULT_PAGE_LIMIT) + + def test_serializer_raises_error_for_page_below_min_value(self): + data = {"page": "0"} + serializer = GetTaskQueryParamsSerializer(data=data) + with self.assertRaises(ValidationError) as context: + serializer.is_valid(raise_exception=True) + self.assertIn("page must be greater than or equal to 1", str(context.exception)) + + def test_serializer_raises_error_for_limit_below_min_value(self): + data = {"limit": "0"} + serializer = GetTaskQueryParamsSerializer(data=data) + with self.assertRaises(ValidationError) as context: + serializer.is_valid(raise_exception=True) + self.assertIn("limit must be greater than or equal to 1", str(context.exception)) + + def test_serializer_raises_error_for_limit_above_max_value(self): + data = {"limit": f"{MAX_PAGE_LIMIT + 1}"} + serializer = GetTaskQueryParamsSerializer(data=data) + with self.assertRaises(ValidationError) as context: + serializer.is_valid(raise_exception=True) + self.assertIn(f"Ensure this value is less than or equal to {MAX_PAGE_LIMIT}", str(context.exception)) + + def test_serializer_handles_partial_input_gracefully(self): + data = {"page": "3"} + serializer = GetTaskQueryParamsSerializer(data=data) + self.assertTrue(serializer.is_valid()) + self.assertEqual(serializer.validated_data["page"], 3) + self.assertEqual(serializer.validated_data["limit"], DEFAULT_PAGE_LIMIT) + + def test_serializer_ignores_undefined_extra_fields(self): + data = {"page": "2", "limit": "5", "extra_field": "ignored"} + serializer = GetTaskQueryParamsSerializer(data=data) + self.assertTrue(serializer.is_valid()) + self.assertEqual(serializer.validated_data["page"], 2) + self.assertEqual(serializer.validated_data["limit"], 5) + self.assertNotIn("extra_field", serializer.validated_data) diff --git a/todo/tests/unit/views/__init__.py b/todo/tests/unit/views/__init__.py new file mode 100644 index 0000000..84a4d93 --- /dev/null +++ b/todo/tests/unit/views/__init__.py @@ -0,0 +1 @@ +# Added this because without this file Django isn't able to auto detect the test files diff --git a/todo/tests/unit/test_health.py b/todo/tests/unit/views/test_health.py similarity index 100% rename from todo/tests/unit/test_health.py rename to todo/tests/unit/views/test_health.py diff --git a/todo/urls.py b/todo/urls.py new file mode 100644 index 0000000..9264115 --- /dev/null +++ b/todo/urls.py @@ -0,0 +1,9 @@ +from django.urls import path +from todo.views.task import TaskView +from todo.views.health import HealthView + + +urlpatterns = [ + path("tasks", TaskView.as_view(), name="tasks"), + path("health", HealthView.as_view(), name="health"), +] diff --git a/todo/views.py b/todo/views/health.py similarity index 91% rename from todo/views.py rename to todo/views/health.py index 6487a3c..882a6af 100644 --- a/todo/views.py +++ b/todo/views/health.py @@ -1,6 +1,6 @@ from rest_framework.views import APIView from rest_framework.response import Response -from .constants.health import AppHealthStatus, ComponentHealthStatus +from todo.constants.health import AppHealthStatus, ComponentHealthStatus from todo_project.db.config import DatabaseManager database_manager = DatabaseManager() diff --git a/todo/views/task.py b/todo/views/task.py new file mode 100644 index 0000000..b409cd8 --- /dev/null +++ b/todo/views/task.py @@ -0,0 +1,12 @@ +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status +from rest_framework.request import Request +from todo.serializers.get_tasks_serializer import GetTaskQueryParamsSerializer + + +class TaskView(APIView): + def get(self, request: Request): + query = GetTaskQueryParamsSerializer(data=request.query_params) + query.is_valid(raise_exception=True) + return Response({}, status.HTTP_200_OK) diff --git a/todo_project/settings/base.py b/todo_project/settings/base.py index 2d4fd9e..194d397 100644 --- a/todo_project/settings/base.py +++ b/todo_project/settings/base.py @@ -49,4 +49,5 @@ "rest_framework.renderers.JSONRenderer", ], "UNAUTHENTICATED_USER": None, + "EXCEPTION_HANDLER": "todo.exceptions.exception_handler.handle_exception", } diff --git a/todo_project/urls.py b/todo_project/urls.py index f5374b4..e3c7181 100644 --- a/todo_project/urls.py +++ b/todo_project/urls.py @@ -1,6 +1,5 @@ -from django.urls import path -from todo import views +from django.urls import path, include urlpatterns = [ - path("health", views.HealthView.as_view(), name="health"), + path("v1/", include("todo.urls"), name="api"), ]