Skip to content

Issue 431: return all exceptions as JSON:API error objects #437

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

Closed
wants to merge 5 commits into from
Closed
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: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,15 @@ pip-delete-this-directory.txt

# PyTest cache
.cache/
.pytest_cache/


# Tox
.tox/
.cache/
.python-version
.coverage
htmlcov/

# VirtualEnv
.venv/
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
v2.5.0 - [unreleased]

* Add new pagination classes based on JSON:API query parameter *recommendations*:
* JsonApiPageNumberPagination and JsonApiLimitOffsetPagination. See [usage docs](docs/usage.md#pagination).
* Deprecates PageNumberPagination and LimitOffsetPagination.
* Add exception handling improvements:
* Document JSON_API_UNIFORM_EXCEPTIONS setting.
* Catch all exceptions not caught by DRF and format as JSON API error objects instead of returning HTML error pages.
* handle missing fields exception

v2.4.0 - Released January 25, 2018

Expand Down
8 changes: 8 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@ class MyLimitPagination(JsonApiLimitOffsetPagination):
max_limit = None
```

### Exception handling

For the `exception_handler` class, if the optional `JSON_API_UNIFORM_EXCEPTIONS` is set to True,
all exceptions will respond with the JSON API [error format](http://jsonapi.org/format/#error-objects).

When `JSON_API_UNIFORM_EXCEPTIONS` is False (the default), non-JSON API views will respond
with the normal DRF error format.

### Performance Testing

If you are trying to see if your viewsets are configured properly to optimize performance,
Expand Down
38 changes: 38 additions & 0 deletions example/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
import json
import sys

import pytest
from django.test import RequestFactory
from django.utils import timezone
from rest_framework.reverse import reverse
from rest_framework.test import APITestCase, force_authenticate

from rest_framework_json_api.utils import format_resource_type
from rest_framework_json_api.views import ModelViewSet

from . import TestBase
from .. import views
from example.models import Author, Blog, Comment, Entry
from example.serializers import BlogSerializer

try:
from unittest import mock
except ImportError:
import mock


class TestRelationshipView(APITestCase):
Expand Down Expand Up @@ -280,3 +289,32 @@ def test_no_content_response(self):
response = self.client.delete(url)
assert response.status_code == 204, response.rendered_content.decode()
assert len(response.rendered_content) == 0, response.rendered_content.decode()


class BadBlogViewSet(ModelViewSet):
queryset = Blog.objects.all()
serializer_class = BlogSerializer

def get_queryset(self, *args, **kwargs):
return self.queryset.filter(foo=1)


@pytest.mark.xfail((sys.version_info.major, sys.version_info.minor) == (2, 7),
reason="python2.7 mock raises "
"'unbound method get_queryset() must be called with BadBlogViewSet "
"instance as first argument (got nothing instead)' "
"rather than working properly as does python3.x mock")
class TestViewExceptions(TestBase):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For new tests I think we should use plain pytest and not unittest. In the long we should get rid of legacy unittest anyway.

Also question is why mocking is needed here. I think if we added BadBlogViewSet into the routing it could be simply called.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was frankly confused by the 3-levels of tests/, tests/unit, tests/integration. Any kind of more detail in the contributing doc you could add would be appreciated.

I can move BadBlogViewSet into the example app views. I based the mocking approach on other projects I've seen where the tests are self-contained in the test code.

def setUp(self):
self.blog = Blog.objects.create(name='Some Blog', tagline="It's a blog")

@mock.patch(
'example.views.BlogViewSet.get_queryset',
new=BadBlogViewSet.get_queryset)
def test_field_error_exception(self):
url = '/blogs'
response = self.client.get(url)
self.assertEqual(response.data[0]['code'], "field_error",
msg=response.data[0]['detail'])
self.assertEqual(response.data[0]['source']['parameter'], "foo",
msg=response.data[0]['detail'])
52 changes: 51 additions & 1 deletion rest_framework_json_api/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import re

from django.core.exceptions import FieldDoesNotExist, FieldError
from django.utils.translation import ugettext_lazy as _
from rest_framework import exceptions, status
from rest_framework.response import Response

from rest_framework_json_api import utils

Expand All @@ -14,6 +18,52 @@ def rendered_with_json_api(view):
return False


def unhandled_drf_exception_handler(exc, context):
"""
Deal with exceptions that DRF doesn't catch and return a JSON:API errors object.

For model query parameters, attempt to identify the parameter that caused the exception:
"Cannot resolve keyword 'xname' into field. Choices are: name, title, ...."
Unfortunately there's no "clean" way to identify which field caused the exception other than
parsing the error message. If the parse fails, a more generic error is reported.

Even a 500 error for a jsonapi view should return a jsonapi error object, not an HTML response.
"""
if not rendered_with_json_api(context['view']):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should move this check to exception_handler this way we can avoid duplicated code as this same code bit is already there on line 82, which would need to be moved upwards.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, will take a look.

return None
keymatch = re.compile(r"^Cannot resolve keyword '(?P<keyword>\w+)' into field.")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be moved into the if as it is only needed there

if isinstance(exc, (FieldError, FieldDoesNotExist,)):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure whether this is an 400 error, as it is not dependen on the request but on the actual code which is erroneousness. Specification says for 400 The request could not be understood by the server due to malformed syntax. The client SHOULD NOT repeat the request without modifications. But when looking at the code this rather looks like an internal server error to me.

matched = keymatch.match(str(exc))
bad_kw = matched.group("keyword") if matched else "?"
status_code = 400
errors = [
{
"status": status_code,
"code": "field_error",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not think we should assign a code. In the
specification it states for code an application-specific error code, expressed as a string value..

rest json api framework is not an application though but a framework so I think we shoulnd't set this but only the implementing application if desired.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather err on the side of providing more information to the client (e.g. field_error provides more useful information than just a 4xx status code).

"title": "Field error",
"detail": str(exc),
"source": {
"pointer": context["request"].path,
"parameter": bad_kw
}
}
]
else:
status_code = 500
errors = [
{
"status": status_code,
"code": "server_error",
"title": "Internal Server Error",
"detail": str(exc),
"source": {
"pointer": context["request"].path,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A pointer should be a JSON path but we do not really know what json path as the failing request might be simply a GET request. So I think we should remove pointer altogether.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See prior comment; When this is exception is raised as part of a viewset GET it is (maybe?) caused by an incorrect field name.

}
}
]
return Response(errors, status=status_code)


def exception_handler(exc, context):
# Import this here to avoid potential edge-case circular imports, which
# crashes with:
Expand All @@ -26,7 +76,7 @@ def exception_handler(exc, context):
# Render exception with DRF
response = drf_exception_handler(exc, context)
if not response:
return response
return unhandled_drf_exception_handler(exc, context)

# Use regular DRF format if not rendered by DRF JSON API and not uniform
is_json_api_view = rendered_with_json_api(context['view'])
Expand Down
5 changes: 4 additions & 1 deletion tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@ deps =
django111: Django>=1.11,<1.12
drf36: djangorestframework>=3.6.3,<3.7
drf37: djangorestframework>=3.7.0,<3.8
coverage

setenv =
PYTHONPATH = {toxinidir}
DJANGO_SETTINGS_MODULE=example.settings.test

commands =
python setup.py test {posargs}
coverage erase
coverage run setup.py test {posargs}
coverage html --include=rest_framework_json_api/*

[testenv:isort]
deps =
Expand Down