Repository: django-json-api/django-rest-framework-json-api

Issue:
Improvements for pagination, sorting, filtering, and exception h…
…andling

* Pagination updates:
  * Improve existing documentation of pagination, mixins
    * Document LimitOffsetPagination
    * Describe how to override pagination class query parameter names.
    * Remove reference to PAGINATE_BY_PARAM which was deprecated in DRF 3.2.
    * Document SparseFieldsetsMixin
  * Add new default settings for pagination query parameters and maximum size.
* Add combinable mixins for filter and sort query parameters and make MultiplIDMixin combinable.
* Exceptions updates:
  * Document JSON_API_UNIFORM_EXCEPTIONS setting.
  * handle missing fields exception thrown by new filter and sort Mixins as a 400 error.
  * Catch all exceptions not caught by DRF and format as JSON API error objects instead of returning HTML error pages.


Discussion:
Still TODO: write test cases for the new code, but I wanted to get a chance for others to take a look and provide feedback.
# [Codecov](https://codecov.io/gh/django-json-api/django-rest-framework-json-api/pull/416?src=pr&el=h1) Report
> Merging [#416](https://codecov.io/gh/django-json-api/django-rest-framework-json-api/pull/416?src=pr&el=desc) into [master](https://codecov.io/gh/django-json-api/django-rest-framework-json-api/commit/e501b0445da047f6672d7596486ceb9b027a66c7?src=pr&el=desc) will **increase** coverage by `0.21%`.
> The diff coverage is `97.39%`.

[![Impacted file tree graph](https://codecov.io/gh/django-json-api/django-rest-framework-json-api/pull/416/graphs/tree.svg?token=kJ4T4hcVh8&src=pr&height=150&width=650)](https://codecov.io/gh/django-json-api/django-rest-framework-json-api/pull/416?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #416      +/-   ##
==========================================
+ Coverage   91.75%   91.96%   +0.21%     
==========================================
  Files          55       55              
  Lines        2923     3026     +103     
==========================================
+ Hits         2682     2783     +101     
- Misses        241      243       +2
```


| [Impacted Files](https://codecov.io/gh/django-json-api/django-rest-framework-json-api/pull/416?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [example/tests/test\_views.py](https://codecov.io/gh/django-json-api/django-rest-framework-json-api/pull/416/diff?src=pr&el=tree#diff-ZXhhbXBsZS90ZXN0cy90ZXN0X3ZpZXdzLnB5) | `100% <100%> (ø)` | :arrow_up: |
| [rest\_framework\_json\_api/pagination.py](https://codecov.io/gh/django-json-api/django-rest-framework-json-api/pull/416/diff?src=pr&el=tree#diff-cmVzdF9mcmFtZXdvcmtfanNvbl9hcGkvcGFnaW5hdGlvbi5weQ==) | `92.85% <100%> (+0.54%)` | :arrow_up: |
| [example/views.py](https://codecov.io/gh/django-json-api/django-rest-framework-json-api/pull/416/diff?src=pr&el=tree#diff-ZXhhbXBsZS92aWV3cy5weQ==) | `91.93% <100%> (+0.41%)` | :arrow_up: |
| [rest\_framework\_json\_api/mixins.py](https://codecov.io/gh/django-json-api/django-rest-framework-json-api/pull/416/diff?src=pr&el=tree#diff-cmVzdF9mcmFtZXdvcmtfanNvbl9hcGkvbWl4aW5zLnB5) | `97.22% <100%> (+9.72%)` | :arrow_up: |
| [rest\_framework\_json\_api/exceptions.py](https://codecov.io/gh/django-json-api/django-rest-framework-json-api/pull/416/diff?src=pr&el=tree#diff-cmVzdF9mcmFtZXdvcmtfanNvbl9hcGkvZXhjZXB0aW9ucy5weQ==) | `85.36% <81.25%> (+0.75%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/django-json-api/django-rest-framework-json-api/pull/416?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/django-json-api/django-rest-framework-json-api/pull/416?src=pr&el=footer). Last update [e501b04...0c2675b](https://codecov.io/gh/django-json-api/django-rest-framework-json-api/pull/416?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

@mblayman Should I be adding stuff to the tox.ini so it matches whats in .travis.yml. flake8  and coverage are missing from it.
I think I've got enough test cases. @mblayman: ready to merge if you see fit.
@mblayman nagging about this PR.
Hey, @n2ygk. Sorry for my super delayed response. I haven't really had time for DJA recently as I'm no longer using it on any regular basis. I've put out a request for a new maintainer (#424) so I hope that someone can step up and act as a proper reviewer for this PR.
@sliverc perhaps you can review this PR?
@n2ygk Thanks a lot for this contribution. I really like that this improves conformance with jsonapi specification! Certainly the way forward.
It is though when the description doesn't fit into the commit message it is an indication that too many changes are in one PR... :wink: 
So could you split this PR up into severals? Maybe one for `pagination`, `sorting`, `filtering` and `exception`?
This would make reviewing easier and less error prone.
I try to review those quickly so you can also do one by one if you want to try to avoid merge conflicts.
@sliverc I could perhaps separate pagination from the others, but the Mixins and exception handler are linked, as the filter and sort mixins throw an exception if a query parameter contains an incorrect field name. I could also make the commit description shorter;-)  If I were to split it into 4, it would I guess have to be in this order: 1) exception handler, 2) sort, 3) filter, 4) pagination. And then I'd have to split up the test case and documentation changes into 4 pieces as well. That's a lot of work. Maybe you can let me slide this time?

Let me add some general comments before we decide how or whether we want to split this PR up.

* I do not think `SortMixin` is actually needed, rest framework already has such a feature with [OrderingFilter](http://www.django-rest-framework.org/api-guide/filtering/#orderingfilter). The only thing which needs to be configured is `ORDERING_PARAM: 'sort'`. I guess it would be good to add this to the documentation

* Also the `FilterMixin` should not be done as view mixin but rather [DjangoFilterBackend](http://www.django-rest-framework.org/api-guide/filtering/#djangofilterbackend) be used. There is one difference between the filter backend and the json api specification which is that filters are added as 'filter[' and not simple query params. I think it is best to derive from `DjangoFilterBackend` to add this feature though.

* On pagination. Not sure how ideal it is that all different pagination classes need to be adjusted for this to work. Maybe we could think of a `Middleware` which passes the query parameters and renames them as default pagination classes need it (without the `page[`]). Also `filter[` could be done this way potentially.

* On exception handling. I haven't had a look at the DRF implementation but maybe a change in `DRF` could make this easier for us instead of doing regex matching.
@sliverc I'm not familiar with how the DjangoFilterBackends are configured. I'll have to read up on that. I based my stuff on the MultipleIDMixin example.
I haven't noticed that there is a `MultipleIDMixin` till #415 

I think though we should rather deprecate it, as firstly I do not think the json api specification mentions that this is possible at all and I also don't think it is the right way to implement this as DRF has better options to do so (e.g. filter backends).

Question is how we want to move forward with this PR. As it implements quite a few things which we might need to consider how they are best implemented I would suggest we close this PR and we can open new PRs for each area of problem? (Not sure when I will get around but a few problems you are trying to address I certainly also encountered in the past and would like to see fixed)

What do you think? Or do you have any other suggestions?
@sliverc wrote:

> I haven't noticed that there is a MultipleIDMixin till #415

> I think though we should rather deprecate it, as firstly I do not think the json api specification mentions that this is possible at all and I also don't think it is the right way to implement this as DRF has better options to do so (e.g. filter backends).

MultipleIDMixin was added by @gaker in 2014. The jsonapi spec wasn't [finalized](http://jsonapi.org/about/) until 2015 so things may have changed. Certainly the way to do this per jsonapi 1.0 would be to use `&filter[id]=id1,id2,...` so I agree about deprecation. I'm also OK with just leaving it as it's not used unless a developer adds it to their code. So we could deprecate it in the documentation.

> Question is how we want to move forward with this PR. As it implements quite a few things which we might need to consider how they are best implemented I would suggest we close this PR and we can open new PRs for each area of problem? (Not sure when I will get around but a few problems you are trying to address I certainly also encountered in the past and would like to see fixed)

I can close this PR and split it up into exceptions, pagination improvements and mixins (or filter backends if that make more sense -- I will have to research that.)

@sliverc I finally think I might have a decent way to customize `DjangoFilterBackend` to allow `GET /resources/?filter[field]=value.  Maybe something to add to this project or maybe just documentation, since filter syntax is undefined in the spec.  Here's a snippet:
```python
from django_filters.rest_framework import DjangoFilterBackend


class JsonApiFilterBackend(DjangoFilterBackend):
    """
    Override's django_filters.rest_framework.DjangoFilterBackend to use `filter[field]` query parameter.
    """

    def get_filterset_kwargs(self, request, queryset, view):
        """
        Turns filter[<field>]=<value> into <field>=<value> which is what DjangoFilterBackend expects
        N.B. `filter[all]` is special-cased for multi-field keyword searches done by rest_framework.filters.SearchFilter

        TODO: use SearchFilter's `search_param` instead of hardcoding
        """
        data = request.query_params.copy()
        for qp, val in data.items():
            if qp[:7] == 'filter[' and qp[-1] == ']' and qp != 'filter[all]':
                data[qp[7:-1]] = val
                del data[qp]
        return {
            'data': data,
            'queryset': queryset,
            'request': request,
        }
```
I don't know if the upstream project (django-filters) would accept a request to make the query parameters configurable in this way...
 
My settings snippet looks like this:
```python
    'DEFAULT_FILTER_BACKENDS': (
        'rest_framework.filters.OrderingFilter',  # for sort
        'myapp.backends.JsonApiFilterBackend',  # for `filter[field]` filtering
        'rest_framework.filters.SearchFilter',  # for keyword filtering across multiple fields
    ),
    'ORDERING_PARAM': 'sort',
    'SEARCH_PARAM': 'filter[all]',
```

And viewset looks like this:
```python
class CourseViewSet(CourseBaseViewSet):
    queryset = Course.objects.all()
    serializer_class = CourseSerializer
    filterset_fields = ('subject_area_code', 'course_name', 'course_description',)
    search_fields = ('course_name', 'course_description', 'course_identifier', 'course_number',)
```
@n2ygk 
I think to provide a specific json api filter backend is the right way to go. For now I would leave out search filter (potentially something to address in DRF) and concentrate on the filter backend.

As `filter[...]` param is part of the json api spec I think it would be good when we provide such a backend in json api.

I think what such a filter backend should also do is formatting taking `JSON_API_FORMAT_FIELD_NAMES` into account. Otherwise would a filter name be underscored e.g. `field[test_name]` which is not really consistent.

Feel free to open up with a PR with this.
@sliverc I'll start a PR shortly.  Just a few items to get your feedback:

- [x] The filter implementation should be Djangoistic (e.g. use the various django-filter backends, queries via the ORM, etc.
- [x] It should be a relatively simple to use a `filter` which the jsonapi spec intentionally does not define. So it's a "use this at your option for lack of a better one"; we can't claim it complies with the spec because there is none other than this statement: "[The filter query parameter is reserved for filtering data. Servers and clients SHOULD use this key for filtering operations.](http://jsonapi.org/format/#fetching-filtering)" However, there are also some [recommendations](http://jsonapi.org/recommendations/#filtering) that lead to the use of `filter[stuff]`.
- [x] It should properly map between model representation and DJA representations of field names (use `JSON_API_FORMAT_FIELD_NAMES`).
- [ ] Should it map django orm use of `__` (double U+005F LOW LINE, “_”) some other way since double `__` is a [legal component of a member name](http://jsonapi.org/format/#document-member-names-allowed-characters)? So that in djangoistic style one would write `filter[courses__course_terms__term_identifier]=20181` perhaps it should use ["U+002E PERIOD, “.” (used as a separator within relationship paths)"](http://jsonapi.org/format/#document-member-names-reserved-characters): `filter[courses.courses_terms.term_identifier]=20181`.
- [ ] How do we have _all the things we like about the ORM_ such as `__gt` or `__in` in a filter? `filter[courses.course_terms.term_identfier.gt]=2018`? Is that still a `relationship path` component. Since filters are not defined by the spec, I would argue that this still follows the style of the spec.
- [x] It must return a 400 status for unknown query parameters (["If a server encounters a query parameter that does not follow the naming conventions above, and the server does not know how to process it as a query parameter from this specification, it MUST return 400 Bad Request."](http://jsonapi.org/format/#query-parameters)). With the current approach. unknown query parameters are currently just silently ignored.
- [ ] Should it be a single backend filter that one can include in `REST_FRAMEWORK['DEFAULT_FILTER_BACKENDS']` or just documented usage of multiple filters as it is (mostly) now? For example, include all these filters in a single `JsonApiFilterBackend`:
       -  `rest_framework.filters.OrderingFilter` and `ordering_param = 'sort'`
       -  `django_filters.rest_framework.DjangoFilterBackend` to use `filter[field]` query parameter
       -  `rest_framework.filters.SearchFilter` to use `search_param=filter[all]`
      Probably keep them separate to allow the most configurability for the developer, I think. Maybe with a better way to override the `REST_FRAMEWORK` defaults?

Thanks for summarizing. The points I ticked above look good to me.

Concerning the others points some thoughts:
DJA takes care of json api specification details. Everything which is not covered by the specification should be left to other libraries with a potential glue in DJA if necessary.

This means we should not worry about how filter are named, whether __ or gt are allowed etc. What we should worry about is that the right query param name is used (`filter[...]` as defined in the spec) and error codes as you have outlined (Thanks for collecting all the json api specification links. Really helpful!).

django-filter has its own way of defining filters which is fairly static (no dynamic filters). Maybe we also add a glue to https://github.com/philipn/django-rest-framework-filters which is more dynamic for users which prefer Django ORM specific filters.

What do you think?

To keep it simple and get started I would create a DJA version of Django Filter backend and once this is integrated can be expanded.
@sliverc You don't like rewriting `.` as `__`? That gets us proper relationship path component separation.
I think in a first implementation I would certainly leave it out and keep it simple. Once it is established we can still think about improving it (although the specification is silent on this - it only talks about member names but filter is not a member).
Actually a filter is a member; that’s what goes inside the brackets per the
examples. At least that’s my reading of it.

On Tue, Aug 7, 2018 at 3:03 AM Oliver Sauder <notifications@github.com>
wrote:

> I think in a first implementation I would certainly leave it out and keep
> it simple. Once it is established we can still think about improving it
> (although the specification is silent on this - it only talks about member
> names but filter is not a member).
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/django-json-api/django-rest-framework-json-api/pull/416#issuecomment-410955263>,
> or mute the thread
> <https://github.com/notifications/unsubscribe-auth/AEJ5d5OFCwlf3Z6V9ac2bwRU3OvjV9Y-ks5uOTujgaJpZM4S2L4M>
> .
>

Interesting I understand the specification completely different :smile: 

> Note: JSON API is agnostic about the strategies supported by a server. The filter query parameter can be used as the basis for any number of filtering strategies.

According to this is anything is allowed as filter as long as it uses the filter query param e.g. also `filter[search]` or anything else. The rest is up to the designer of the api.
The example does show member names but this doesn't have to be this way as JSON API is filter strategy agnostic. It's just an example in the end. Therefore I think DJA should also be agnostic to it and not decide for the api designer how to do filtering. This does mean though that several options can be provided of course which I think is best done with integrating different filtering libraries.


@sliverc is there an inverse function for `JSON_API_FORMAT_FIELD_NAMES`? I see `utils.format_field_names` for one direction... Can it even be reversed? I need to accept `sort=keyName` and convert it to internal model's field name `key_name` or something... in order to pass the correct keys to OrderingFilter and so on.

-- https://django-rest-framework-json-api.readthedocs.io/en/stable/usage.html#inflecting-object-and-relation-keys
Copied from JSONParser where it basicially needs to do the same thing:
```python
# convert back to python/rest_framework's preferred underscore format
return utils._format_object(attributes, 'underscore')
```

Resolving patch:
diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py
--- a/rest_framework_json_api/utils.py
+++ b/rest_framework_json_api/utils.py
@@ -312,29 +312,25 @@ def format_drf_errors(response, context, exc):
     # handle generic errors. ValidationError('test') in a view for example
     if isinstance(response.data, list):
         for message in response.data:
-            errors.append(format_error_object(message, '/data', response))
+            errors.extend(format_error_object(message, '/data', response))
     # handle all errors thrown from serializers
     else:
         for field, error in response.data.items():
             field = format_value(field)
             pointer = '/data/attributes/{}'.format(field)
-            # see if they passed a dictionary to ValidationError manually
-            if isinstance(error, dict):
-                errors.append(error)
-            elif isinstance(exc, Http404) and isinstance(error, str):
+            if isinstance(exc, Http404) and isinstance(error, str):
                 # 404 errors don't have a pointer
-                errors.append(format_error_object(error, None, response))
+                errors.extend(format_error_object(error, None, response))
             elif isinstance(error, str):
                 classes = inspect.getmembers(exceptions, inspect.isclass)
                 # DRF sets the `field` to 'detail' for its own exceptions
                 if isinstance(exc, tuple(x[1] for x in classes)):
                     pointer = '/data'
-                errors.append(format_error_object(error, pointer, response))
+                errors.extend(format_error_object(error, pointer, response))
             elif isinstance(error, list):
-                for message in error:
-                    errors.append(format_error_object(message, pointer, response))
+                errors.extend(format_error_object(error, pointer, response))
             else:
-                errors.append(format_error_object(error, pointer, response))
+                errors.extend(format_error_object(error, pointer, response))
 
     context['view'].resource_name = 'errors'
     response.data = errors
@@ -343,18 +339,49 @@ def format_drf_errors(response, context, exc):
 
 
 def format_error_object(message, pointer, response):
-    error_obj = {
-        'detail': message,
-        'status': encoding.force_str(response.status_code),
-    }
-    if pointer is not None:
-        error_obj['source'] = {
-            'pointer': pointer,
+    errors = []
+    if isinstance(message, dict):
+
+        # as there is no required field in error object we check that all fields are string
+        # except links and source which might be a dict
+        is_custom_error = all([
+            isinstance(value, str)
+            for key, value in message.items() if key not in ['links', 'source']
+        ])
+
+        if is_custom_error:
+            if 'source' not in message:
+                message['source'] = {}
+            message['source'] = {
+                'pointer': pointer,
+            }
+            errors.append(message)
+        else:
+            for k, v in message.items():
+                errors.extend(format_error_object(v, pointer + '/{}'.format(k), response))
+    elif isinstance(message, list):
+        for num, error in enumerate(message):
+            if isinstance(error, (list, dict)):
+                new_pointer = pointer + '/{}'.format(num)
+            else:
+                new_pointer = pointer
+            if error:
+                errors.extend(format_error_object(error, new_pointer, response))
+    else:
+        error_obj = {
+            'detail': message,
+            'status': encoding.force_str(response.status_code),
         }
-    code = getattr(message, "code", None)
-    if code is not None:
-        error_obj['code'] = code
-    return error_obj
+        if pointer is not None:
+            error_obj['source'] = {
+                'pointer': pointer,
+            }
+        code = getattr(message, "code", None)
+        if code is not None:
+            error_obj['code'] = code
+        errors.append(error_obj)
+
+    return errors
 
 
 def format_errors(data):


Test patch:
diff --git a/example/tests/snapshots/__init__.py b/example/tests/snapshots/__init__.py
new file mode 100644
diff --git a/example/tests/snapshots/snap_test_errors.py b/example/tests/snapshots/snap_test_errors.py
new file mode 100644
--- /dev/null
+++ b/example/tests/snapshots/snap_test_errors.py
@@ -0,0 +1,121 @@
+# -*- coding: utf-8 -*-
+# snapshottest: v1 - https://goo.gl/zC4yUc
+from __future__ import unicode_literals
+
+from snapshottest import Snapshot
+
+
+snapshots = Snapshot()
+
+snapshots['test_first_level_attribute_error 1'] = {
+    'errors': [
+        {
+            'code': 'required',
+            'detail': 'This field is required.',
+            'source': {
+                'pointer': '/data/attributes/headline'
+            },
+            'status': '400'
+        }
+    ]
+}
+
+snapshots['test_first_level_custom_attribute_error 1'] = {
+    'errors': [
+        {
+            'detail': 'Too short',
+            'source': {
+                'pointer': '/data/attributes/body-text'
+            },
+            'title': 'Too Short title'
+        }
+    ]
+}
+
+snapshots['test_second_level_array_error 1'] = {
+    'errors': [
+        {
+            'code': 'required',
+            'detail': 'This field is required.',
+            'source': {
+                'pointer': '/data/attributes/comments/0/body'
+            },
+            'status': '400'
+        }
+    ]
+}
+
+snapshots['test_second_level_dict_error 1'] = {
+    'errors': [
+        {
+            'code': 'required',
+            'detail': 'This field is required.',
+            'source': {
+                'pointer': '/data/attributes/comment/body'
+            },
+            'status': '400'
+        }
+    ]
+}
+
+snapshots['test_third_level_array_error 1'] = {
+    'errors': [
+        {
+            'code': 'required',
+            'detail': 'This field is required.',
+            'source': {
+                'pointer': '/data/attributes/comments/0/attachments/0/data'
+            },
+            'status': '400'
+        }
+    ]
+}
+
+snapshots['test_third_level_custom_array_error 1'] = {
+    'errors': [
+        {
+            'code': 'invalid',
+            'detail': 'Too short data',
+            'source': {
+                'pointer': '/data/attributes/comments/0/attachments/0/data'
+            },
+            'status': '400'
+        }
+    ]
+}
+
+snapshots['test_third_level_dict_error 1'] = {
+    'errors': [
+        {
+            'code': 'required',
+            'detail': 'This field is required.',
+            'source': {
+                'pointer': '/data/attributes/comments/0/attachment/data'
+            },
+            'status': '400'
+        }
+    ]
+}
+
+snapshots['test_many_third_level_dict_errors 1'] = {
+    'errors': [
+        {
+            'code': 'required',
+            'detail': 'This field is required.',
+            'source': {
+                'pointer': '/data/attributes/comments/0/attachment/data'
+            },
+            'status': '400'
+        },
+        {
+            'code': 'required',
+            'detail': 'This field is required.',
+            'source': {
+                'pointer': '/data/attributes/comments/0/body'
+            },
+            'status': '400'
+        }
+    ]
+}
+
+snapshots['test_deprecation_warning 1'] = 'Rendering nested serializer as relationship is deprecated. Use `ResourceRelatedField` instead if DummyNestedSerializer in serializer example.tests.test_errors.test_deprecation_warning.<locals>.DummySerializer should remain a relationship. Otherwise set JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE to True to render nested serializer as nested json attribute'
diff --git a/example/tests/test_errors.py b/example/tests/test_errors.py
new file mode 100644
--- /dev/null
+++ b/example/tests/test_errors.py
@@ -0,0 +1,240 @@
+import pytest
+from django.conf.urls import url
+from django.test import override_settings
+from django.urls import reverse
+from rest_framework import views
+
+from rest_framework_json_api import serializers
+
+from example.models import Blog
+
+
+# serializers
+class CommentAttachmentSerializer(serializers.Serializer):
+    data = serializers.CharField(allow_null=False, required=True)
+
+    def validate_data(self, value):
+        if value and len(value) < 10:
+            raise serializers.ValidationError('Too short data')
+
+
+class CommentSerializer(serializers.Serializer):
+    attachments = CommentAttachmentSerializer(many=True, required=False)
+    attachment = CommentAttachmentSerializer(required=False)
+    one_more_attachment = CommentAttachmentSerializer(required=False)
+    body = serializers.CharField(allow_null=False, required=True)
+
+
+class EntrySerializer(serializers.Serializer):
+    blog = serializers.IntegerField()
+    comments = CommentSerializer(many=True, required=False)
+    comment = CommentSerializer(required=False)
+    headline = serializers.CharField(allow_null=True, required=True)
+    body_text = serializers.CharField()
+
+    def validate(self, attrs):
+        body_text = attrs['body_text']
+        if len(body_text) < 5:
+            raise serializers.ValidationError({'body_text': {
+                'title': 'Too Short title', 'detail': 'Too short'}
+            })
+
+
+# view
+class DummyTestView(views.APIView):
+    serializer_class = EntrySerializer
+    resource_name = 'entries'
+
+    def post(self, request, *args, **kwargs):
+        serializer = self.serializer_class(data=request.data)
+        serializer.is_valid(raise_exception=True)
+
+
+urlpatterns = [
+    url('entries-nested', DummyTestView.as_view(),
+        name='entries-nested-list')
+]
+
+
+@pytest.fixture(scope='function')
+def some_blog(db):
+    return Blog.objects.create(name='Some Blog', tagline="It's a blog")
+
+
+def perform_error_test(client, data):
+    with override_settings(
+            JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE=True,
+            ROOT_URLCONF=__name__
+    ):
+        url = reverse('entries-nested-list')
+        response = client.post(url, data=data)
+
+    return response.json()
+
+
+def test_first_level_attribute_error(client, some_blog, snapshot):
+    data = {
+        'data': {
+            'type': 'entries',
+            'attributes': {
+                'blog': some_blog.pk,
+                'bodyText': 'body_text',
+            }
+        }
+    }
+    snapshot.assert_match(perform_error_test(client, data))
+
+
+def test_first_level_custom_attribute_error(client, some_blog, snapshot):
+    data = {
+        'data': {
+            'type': 'entries',
+            'attributes': {
+                'blog': some_blog.pk,
+                'body-text': 'body',
+                'headline': 'headline'
+            }
+        }
+    }
+    with override_settings(JSON_API_FORMAT_FIELD_NAMES='dasherize'):
+        snapshot.assert_match(perform_error_test(client, data))
+
+
+def test_second_level_array_error(client, some_blog, snapshot):
+    data = {
+        'data': {
+            'type': 'entries',
+            'attributes': {
+                'blog': some_blog.pk,
+                'bodyText': 'body_text',
+                'headline': 'headline',
+                'comments': [
+                    {
+                    }
+                ]
+            }
+        }
+    }
+
+    snapshot.assert_match(perform_error_test(client, data))
+
+
+def test_second_level_dict_error(client, some_blog, snapshot):
+    data = {
+        'data': {
+            'type': 'entries',
+            'attributes': {
+                'blog': some_blog.pk,
+                'bodyText': 'body_text',
+                'headline': 'headline',
+                'comment': {}
+            }
+        }
+    }
+
+    snapshot.assert_match(perform_error_test(client, data))
+
+
+def test_third_level_array_error(client, some_blog, snapshot):
+    data = {
+        'data': {
+            'type': 'entries',
+            'attributes': {
+                'blog': some_blog.pk,
+                'bodyText': 'body_text',
+                'headline': 'headline',
+                'comments': [
+                    {
+                        'body': 'test comment',
+                        'attachments': [
+                            {
+                            }
+                        ]
+                    }
+                ]
+            }
+        }
+    }
+
+    snapshot.assert_match(perform_error_test(client, data))
+
+
+def test_third_level_custom_array_error(client, some_blog, snapshot):
+    data = {
+        'data': {
+            'type': 'entries',
+            'attributes': {
+                'blog': some_blog.pk,
+                'bodyText': 'body_text',
+                'headline': 'headline',
+                'comments': [
+                    {
+                        'body': 'test comment',
+                        'attachments': [
+                            {
+                                'data': 'text'
+                            }
+                        ]
+                    }
+                ]
+            }
+        }
+    }
+
+    snapshot.assert_match(perform_error_test(client, data))
+
+
+def test_third_level_dict_error(client, some_blog, snapshot):
+    data = {
+        'data': {
+            'type': 'entries',
+            'attributes': {
+                'blog': some_blog.pk,
+                'bodyText': 'body_text',
+                'headline': 'headline',
+                'comments': [
+                    {
+                        'body': 'test comment',
+                        'attachment': {}
+                    }
+                ]
+            }
+        }
+    }
+
+    snapshot.assert_match(perform_error_test(client, data))
+
+
+def test_many_third_level_dict_errors(client, some_blog, snapshot):
+    data = {
+        'data': {
+            'type': 'entries',
+            'attributes': {
+                'blog': some_blog.pk,
+                'bodyText': 'body_text',
+                'headline': 'headline',
+                'comments': [
+                    {
+                        'attachment': {}
+                    }
+                ]
+            }
+        }
+    }
+
+    snapshot.assert_match(perform_error_test(client, data))
+
+
+@pytest.mark.filterwarnings('default::DeprecationWarning:rest_framework_json_api.serializers')
+def test_deprecation_warning(recwarn, settings, snapshot):
+    settings.JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE = False
+
+    class DummyNestedSerializer(serializers.Serializer):
+        field = serializers.CharField()
+
+    class DummySerializer(serializers.Serializer):
+        nested = DummyNestedSerializer(many=True)
+
+    assert len(recwarn) == 1
+    warning = recwarn.pop(DeprecationWarning)
+    snapshot.assert_match(str(warning.message))
diff --git a/example/tests/test_generic_viewset.py b/example/tests/test_generic_viewset.py
--- a/example/tests/test_generic_viewset.py
+++ b/example/tests/test_generic_viewset.py
@@ -95,11 +95,6 @@ def test_custom_validation_exceptions(self):
         """
         expected = {
             'errors': [
-                {
-                    'id': 'armageddon101',
-                    'detail': 'Hey! You need a last name!',
-                    'meta': 'something',
-                },
                 {
                     'status': '400',
                     'source': {
@@ -108,6 +103,12 @@ def test_custom_validation_exceptions(self):
                     'detail': 'Enter a valid email address.',
                     'code': 'invalid',
                 },
+                {
+                    'id': 'armageddon101',
+                    'detail': 'Hey! You need a last name!',
+                    'meta': 'something',
+                    'source': {'pointer': '/data/attributes/lastName'}
+                },
             ]
         }
         response = self.client.post('/identities', {