Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Setup a basic view for task api and add query params validator #11

Open
wants to merge 7 commits into
base: feat-get-todo-api-add-repository
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions todo/constants/task.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from enum import Enum

DEFAULT_PAGE_LIMIT = 20
MAX_PAGE_LIMIT = 200


class TaskStatus(Enum):
TODO = "TODO"
Expand Down
21 changes: 21 additions & 0 deletions todo/dto/responses/error_response.py
Original file line number Diff line number Diff line change
@@ -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]
36 changes: 36 additions & 0 deletions todo/exceptions/exception_handler.py
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions todo/serializers/get_tasks_serializer.py
Original file line number Diff line number Diff line change
@@ -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",
},
)
1 change: 1 addition & 0 deletions todo/tests/unit/exceptions/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Added this because without this file Django isn't able to auto detect the test files
73 changes: 73 additions & 0 deletions todo/tests/unit/exceptions/test_exception_handler.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions todo/tests/unit/serializers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Added this because without this file Django isn't able to auto detect the test files
56 changes: 56 additions & 0 deletions todo/tests/unit/serializers/test_get_tasks_serializer.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions todo/tests/unit/views/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Added this because without this file Django isn't able to auto detect the test files
File renamed without changes.
9 changes: 9 additions & 0 deletions todo/urls.py
Original file line number Diff line number Diff line change
@@ -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"),
]
2 changes: 1 addition & 1 deletion todo/views.py → todo/views/health.py
Original file line number Diff line number Diff line change
@@ -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()
Expand Down
12 changes: 12 additions & 0 deletions todo/views/task.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions todo_project/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,5 @@
"rest_framework.renderers.JSONRenderer",
],
"UNAUTHENTICATED_USER": None,
"EXCEPTION_HANDLER": "todo.exceptions.exception_handler.handle_exception",
}
5 changes: 2 additions & 3 deletions todo_project/urls.py
Original file line number Diff line number Diff line change
@@ -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"),
]
Loading