From fa266f15ba9dade600871944269cc24286629c7e Mon Sep 17 00:00:00 2001 From: Jerel Unruh Date: Fri, 22 Jan 2016 10:40:54 -0600 Subject: [PATCH 1/3] Include version number in pip example to close #191 --- README.rst | 2 +- docs/getting-started.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 1926a402..4d4a1e4f 100644 --- a/README.rst +++ b/README.rst @@ -80,7 +80,7 @@ From PyPI :: - $ pip install djangorestframework-jsonapi + $ pip install djangorestframework-jsonapi==2.0.0-beta.1 From Source diff --git a/docs/getting-started.md b/docs/getting-started.md index cc2af2b6..2feda522 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -59,7 +59,7 @@ like the following: From PyPI - pip install djangorestframework-jsonapi + pip install djangorestframework-jsonapi==2.0.0-beta.1 From Source From b9ccd9d5059afe9d6b248433cf9625a0fea21869 Mon Sep 17 00:00:00 2001 From: Jerel Unruh Date: Fri, 29 Apr 2016 13:30:03 -0500 Subject: [PATCH 2/3] v2.0.0-beta.2 -> v2.0.0 * docs: note about importing serializers * Document ResourceRelatedField and RelationshipView * Updated pip install instructions for 2.0.0-beta.2 * Add LimitOffsetPagination * Dont let the offset go into negative space * Add basic unit test for LimitOffsetPagination * Support deeply nested includes Allow skipping of intermediate included models * Add current tox.ini directory to PYTHONPATH in order to use imports form there Fix regression on PY3 caused by unicode_literals * [FEATURE]: support using get_serializer_class on view * fixed extract_root_meta for lists * Fixed get_resource_name in case of non-model backed serializer. Closes #219 * ResourceRelatedField now accepts serializer methods when many=True * Rename "suggested" posts to "featured" so we can use suggested as many=True * Updated SerializerMethodResourceRelatedField to allow many=True Issue #151 Closes #220 * Correct error responses for projects with different DRF-configurations (#222) * [#214] Add error messages tests. * [#214] Extract formatting DRF errors. * Add example view with custom handle_exception. * Use HTTP 422 for validation error responses. * Add full example of class-configured json api view. * Fixed naming that suggested settings were used to inflect relationship names. JSON_API_FORMAT_RELATION_NAME actually inflected the `type` instead. The relation name is not changable at this time although if it woudl be useful to someone it would be fine to implement it. Closes #136. * Updated changelog * Added a doc note to prefer setting resource_name on serializers or models. Closes #207 * Added get_related_field_name method to RelationshipView * Added get_related_field_name method to RelationshipView * Added docs about field_name_mapping * Updated the readme for testing (#234) * Allow exception handler to be used by normal DRF views: (#233) * Add top-level 'errors' object to non-JSON-API responses * Allow configuring the exception handler to be used _only_ in JSON API views or uniformly across all views * Fix included resource type inconsistency (#229) When setting `resource_name = None`, the related instance's resource name is used in `relationships`, but `None` is used in `included`. This is related to #94 and #124 * Fixes #230. Keep write only fields from having an attribute key * Release v2.0.0 * Update setup.py to classify as production/stable --- .gitignore | 3 + CHANGELOG.md | 14 ++ README.rst | 9 +- docs/api.md | 3 +- docs/usage.md | 208 ++++++++++++++++-- example/factories/__init__.py | 2 +- example/serializers.py | 29 ++- example/settings/dev.py | 2 +- example/settings/test.py | 4 +- example/tests/integration/test_includes.py | 71 +++++- example/tests/integration/test_meta.py | 34 ++- .../integration/test_model_resource_name.py | 5 + .../test_non_paginated_responses.py | 6 + example/tests/integration/test_pagination.py | 3 + example/tests/test_relations.py | 12 +- example/tests/test_serializers.py | 12 +- example/tests/test_views.py | 63 ++++-- example/tests/unit/test_pagination.py | 79 +++++++ .../tests/unit/test_renderer_class_methods.py | 48 ++-- example/tests/unit/test_utils.py | 20 +- example/views.py | 48 +++- rest_framework_json_api/__init__.py | 2 +- rest_framework_json_api/exceptions.py | 78 ++----- rest_framework_json_api/pagination.py | 53 ++++- rest_framework_json_api/relations.py | 42 +++- rest_framework_json_api/renderers.py | 97 ++++---- rest_framework_json_api/serializers.py | 4 +- rest_framework_json_api/utils.py | 90 +++++++- rest_framework_json_api/views.py | 13 +- setup.py | 2 +- tox.ini | 4 +- 31 files changed, 851 insertions(+), 209 deletions(-) create mode 100644 example/tests/unit/test_pagination.py diff --git a/.gitignore b/.gitignore index e5428776..3177afc7 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ pip-delete-this-directory.txt # Tox .tox/ + +# VirtualEnv +.venv/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 67c2d76d..096fd7ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,18 @@ +v2.0.0 + +* Fixed bug where write_only fields still had their keys rendered +* Exception handler can now easily be used on DRF-JA views alongside regular DRF views +* Added `get_related_field_name` for views subclassing RelationshipView to override +* Renamed `JSON_API_FORMAT_RELATION_KEYS` to `JSON_API_FORMAT_TYPES` to match what it was actually doing +* Renamed `JSON_API_PLURALIZE_RELATION_TYPE` to `JSON_API_PLURALIZE_TYPES` +* Documented ResourceRelatedField and RelationshipView +* Added LimitOffsetPagination +* Support deeply nested `?includes=foo.bar.baz` without returning intermediate models (bar) +* Allow a view's serializer_class to be fetched at runtime via `get_serializer_class` +* Added support for `get_root_meta` on list serializers + + v2.0.0-beta.2 * Added JSONAPIMeta class option to models for overriding `resource_name`. #197 diff --git a/README.rst b/README.rst index 905f15df..d9e8a1d5 100644 --- a/README.rst +++ b/README.rst @@ -80,7 +80,7 @@ From PyPI :: - $ pip install djangorestframework-jsonapi==2.0.0-beta.1 + $ pip install djangorestframework-jsonapi From Source @@ -107,9 +107,14 @@ Browse to http://localhost:8000 Running Tests ^^^^^^^^^^^^^ +It is recommended to create a virtualenv for testing. Assuming it is already +installed and activated: + :: - $ python runtests.py + $ pip install -e . + $ pip install -r requirements-development.txt + $ py.test ----- diff --git a/docs/api.md b/docs/api.md index 14da1d8f..a7f8926f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -38,7 +38,7 @@ Gathers the data from serializer fields specified in `meta_fields` and adds it t #### extract_root_meta -`extract_root_meta(serializer, resource, meta)` +`extract_root_meta(serializer, resource)` Calls a `get_root_meta` function on a serializer, if it exists. @@ -47,4 +47,3 @@ Calls a `get_root_meta` function on a serializer, if it exists. `build_json_resource_obj(fields, resource, resource_instance, resource_name)` Builds the resource object (type, id, attributes) and extracts relationships. - diff --git a/docs/usage.md b/docs/usage.md index 7f951e1c..27caee0c 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -34,6 +34,18 @@ record count and a `links` object with the next, previous, first, and last links Pages can be selected with the `page` GET parameter. Page size can be controlled per request via the `PAGINATE_BY_PARAM` query parameter (`page_size` by default). +### Serializers + +It is recommended to import the base serializer classes from this package +rather than from vanilla DRF. For example, + +```python +from rest_framework_json_api import serializers + +class MyModelSerializer(serializers.ModelSerializers): + # ... +``` + ### Setting the resource_name You may manually set the `resource_name` property on views, serializers, or @@ -75,7 +87,10 @@ class Me(models.Model): If you set the `resource_name` on a combination of model, serializer, or view in the same hierarchy, the name will be resolved as following: view > serializer > model. (Ex: A view `resource_name` will always override a -`resource_name` specified on a serializer or model) +`resource_name` specified on a serializer or model). Setting the `resource_name` +on the view should be used sparingly as serializers and models are shared between +multiple endpoints. Setting the `resource_name` on views may result in a different +`type` being set depending on which endpoint the resource is fetched from. ### Inflecting object and relation keys @@ -143,12 +158,12 @@ Example - With format conversion set to `dasherize`: } ``` -#### Relationship types +#### Types -A similar option to JSON\_API\_FORMAT\_RELATION\_KEYS can be set for the relationship names: +A similar option to JSON\_API\_FORMAT\_KEYS can be set for the types: ``` python -JSON_API_FORMAT_RELATION_KEYS = 'dasherize' +JSON_API_FORMAT_TYPES = 'dasherize' ``` Example without format conversion: @@ -156,7 +171,7 @@ Example without format conversion: ``` js { "data": [{ - "type": "identities", + "type": "blog_identity", "id": 3, "attributes": { ... @@ -179,7 +194,7 @@ When set to dasherize: ``` js { "data": [{ - "type": "identities", + "type": "blog-identity", "id": 3, "attributes": { ... @@ -198,7 +213,7 @@ When set to dasherize: It is also possible to pluralize the types like so: ```python -JSON_API_PLURALIZE_RELATION_TYPE = True +JSON_API_PLURALIZE_TYPES = True ``` Example without pluralization: @@ -245,8 +260,168 @@ When set to pluralize: } ``` -Both `JSON_API_PLURALIZE_RELATION_TYPE` and `JSON_API_FORMAT_RELATION_KEYS` can be combined to -achieve different results. +### Related fields + +Because of the additional structure needed to represent relationships in JSON +API, this package provides the `ResourceRelatedField` for serializers, which +works similarly to `PrimaryKeyRelatedField`. By default, +`rest_framework_json_api.serializers.ModelSerializer` will use this for +related fields automatically. It can be instantiated explicitly as in the +following example: + +```python +from rest_framework_json_api import serializers +from rest_framework_json_api.relations import ResourceRelatedField + +from myapp.models import Order, LineItem, Customer + + +class OrderSerializer(serializers.ModelSerializer): + class Meta: + model = Order + + line_items = ResourceRelatedField( + queryset=LineItem.objects, + many=True # necessary for M2M fields & reverse FK fields + ) + + customer = ResourceRelatedField( + queryset=Customer.objects # queryset argument is required + ) # except when read_only=True + +``` + +In the [JSON API spec](http://jsonapi.org/format/#document-resource-objects), +relationship objects contain links to related objects. To make this work +on a serializer we need to tell the `ResourceRelatedField` about the +corresponding view. Use the `HyperlinkedModelSerializer` and instantiate +the `ResourceRelatedField` with the relevant keyword arguments: + +```python +from rest_framework_json_api import serializers +from rest_framework_json_api.relations import ResourceRelatedField + +from myapp.models import Order, LineItem, Customer + + +class OrderSerializer(serializers.ModelSerializer): + class Meta: + model = Order + + line_items = ResourceRelatedField( + queryset=LineItem.objects, + many=True, + related_link_view_name='order-lineitems-list', + related_link_url_kwarg='order_pk', + self_link_view_name='order_relationships' + ) + + customer = ResourceRelatedField( + queryset=Customer.objects, + related_link_view-name='order-customer-detail', + related_link_url_kwarg='order_pk', + self_link_view_name='order-relationships' + ) +``` + +* `related_link_view_name` is the name of the route for the related +view. + +* `related_link_url_kwarg` is the keyword argument that will be passed +to the view that identifies the 'parent' object, so that the results +can be filtered to show only those objects related to the 'parent'. + +* `self_link_view_name` is the name of the route for the `RelationshipView` +(see below). + +In this example, `reverse('order-lineitems-list', kwargs={'order_pk': 3}` +should resolve to something like `/orders/3/lineitems`, and that route +should instantiate a view or viewset for `LineItem` objects that accepts +a keword argument `order_pk`. The +[drf-nested-routers](https://github.com/alanjds/drf-nested-routers) package +is useful for defining such nested routes in your urlconf. + +The corresponding viewset for the `line-items-list` route in the above example +might look like the following. Note that in the typical use case this would be +the same viewset used for the `/lineitems` endpoints; when accessed through +the nested route `/orders//lineitems` the queryset is filtered using +the `order_pk` keyword argument to include only the lineitems related to the +specified order. + +```python +from rest_framework import viewsets + +from myapp.models import LineItem +from myapp.serializers import LineItemSerializer + + +class LineItemViewSet(viewsets.ModelViewSet): + queryset = LineItem.objects + serializer_class = LineItemSerializer + + def get_queryset(self): + queryset = self.queryset + + # if this viewset is accessed via the 'order-lineitems-list' route, + # it wll have been passed the `order_pk` kwarg and the queryset + # needs to be filtered accordingly; if it was accessed via the + # unnested '/lineitems' route, the queryset should include all LineItems + if 'order_pk' in self.kwargs: + order_pk = self.kwargs['order_pk'] + queryset = queryset.filter(order__pk=order_pk]) + + return queryset +``` + +### RelationshipView +`rest_framework_json_api.views.RelationshipView` is used to build +relationship views (see the +[JSON API spec](http://jsonapi.org/format/#fetching-relationships)). +The `self` link on a relationship object should point to the corresponding +relationship view. + +The relationship view is fairly simple because it only serializes +[Resource Identifier Objects](http://jsonapi.org/format/#document-resource-identifier-objects) +rather than full resource objects. In most cases the following is sufficient: + +```python +from rest_framework_json_api.views import RelationshipView + +from myapp.models import Order + + +class OrderRelationshipView(RelationshipView): + queryset = Order.objects + +``` + +The urlconf would need to contain a route like the following: + +```python +url( + regex=r'^orders/(?P[^/.]+/relationships/(?P[^/.]+)$', + view=OrderRelationshipView.as_view(), + name='order-relationships' +) +``` + +The `related_field` kwarg specifies which relationship to use, so +if we are interested in the relationship represented by the related +model field `Order.line_items` on the Order with pk 3, the url would be +`/order/3/relationships/line_items`. On `HyperlinkedModelSerializer`, the +`ResourceRelatedField` will construct the url based on the provided +`self_link_view_name` keyword argument, which should match the `name=` +provided in the urlconf, and will use the name of the field for the +`related_field` kwarg. +Also we can override `related_field` in the url. Let's say we want the url to be: +`/order/3/relationships/order_items` - all we need to do is just add `field_name_mapping` +dict to the class: +```python +field_name_mapping = { + 'line_items': 'order_items' + } +``` + ### Meta @@ -260,10 +435,17 @@ added to the `meta` object within the same `data` as the serializer. To add metadata to the top level `meta` object add: ``` python -def get_root_meta(self, obj): - return { - 'size': len(obj) - } +def get_root_meta(self, resource, many): + if many: + # Dealing with a list request + return { + 'size': len(resource) + } + else: + # Dealing with a detail request + return { + 'foo': 'bar' + } ``` to the serializer. It must return a dict and will be merged with the existing top level `meta`. diff --git a/example/factories/__init__.py b/example/factories/__init__.py index 129ddf98..0119f925 100644 --- a/example/factories/__init__.py +++ b/example/factories/__init__.py @@ -1,5 +1,4 @@ # -*- encoding: utf-8 -*- -from __future__ import unicode_literals import factory from faker import Factory as FakerFactory @@ -22,6 +21,7 @@ class Meta: name = factory.LazyAttribute(lambda x: faker.name()) email = factory.LazyAttribute(lambda x: faker.email()) + bio = factory.RelatedFactory('example.factories.AuthorBioFactory', 'author') class AuthorBioFactory(factory.django.DjangoModelFactory): class Meta: diff --git a/example/serializers.py b/example/serializers.py index c16b7cdf..e259a10b 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -7,10 +7,10 @@ class BlogSerializer(serializers.ModelSerializer): copyright = serializers.SerializerMethodField() - def get_copyright(self, obj): + def get_copyright(self, resource): return datetime.now().year - def get_root_meta(self, obj): + def get_root_meta(self, resource, many): return { 'api_docs': '/docs/api/blogs' } @@ -25,24 +25,33 @@ class EntrySerializer(serializers.ModelSerializer): def __init__(self, *args, **kwargs): # to make testing more concise we'll only output the - # `suggested` field when it's requested via `include` + # `featured` field when it's requested via `include` request = kwargs.get('context', {}).get('request') - if request and 'suggested' not in request.query_params.get('include', []): - self.fields.pop('suggested') + if request and 'featured' not in request.query_params.get('include', []): + self.fields.pop('featured') super(EntrySerializer, self).__init__(*args, **kwargs) included_serializers = { + 'authors': 'example.serializers.AuthorSerializer', 'comments': 'example.serializers.CommentSerializer', - 'suggested': 'example.serializers.EntrySerializer', + 'featured': 'example.serializers.EntrySerializer', } body_format = serializers.SerializerMethodField() + # many related from model comments = relations.ResourceRelatedField( source='comment_set', many=True, read_only=True) + # many related from serializer suggested = relations.SerializerMethodResourceRelatedField( - source='get_suggested', model=Entry, read_only=True) + source='get_suggested', model=Entry, many=True, read_only=True) + # single related from serializer + featured = relations.SerializerMethodResourceRelatedField( + source='get_featured', model=Entry, read_only=True) def get_suggested(self, obj): + return Entry.objects.exclude(pk=obj.pk) + + def get_featured(self, obj): return Entry.objects.exclude(pk=obj.pk).first() def get_body_format(self, obj): @@ -51,7 +60,7 @@ def get_body_format(self, obj): class Meta: model = Entry fields = ('blog', 'headline', 'body_text', 'pub_date', 'mod_date', - 'authors', 'comments', 'suggested',) + 'authors', 'comments', 'featured', 'suggested',) meta_fields = ('body_format',) @@ -73,6 +82,10 @@ class Meta: class CommentSerializer(serializers.ModelSerializer): + included_serializers = { + 'entry': EntrySerializer, + 'author': AuthorSerializer + } class Meta: model = Comment diff --git a/example/settings/dev.py b/example/settings/dev.py index e35ea803..b4b435ca 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -37,7 +37,7 @@ MIDDLEWARE_CLASSES = () JSON_API_FORMAT_KEYS = 'camelize' -JSON_API_FORMAT_RELATION_KEYS = 'camelize' +JSON_API_FORMAT_TYPES = 'camelize' REST_FRAMEWORK = { 'PAGE_SIZE': 5, 'EXCEPTION_HANDLER': 'rest_framework_json_api.exceptions.exception_handler', diff --git a/example/settings/test.py b/example/settings/test.py index d67c5e45..5bb3f45d 100644 --- a/example/settings/test.py +++ b/example/settings/test.py @@ -10,8 +10,8 @@ ROOT_URLCONF = 'example.urls_test' JSON_API_FORMAT_KEYS = 'camelize' -JSON_API_FORMAT_RELATION_KEYS = 'camelize' -JSON_API_PLURALIZE_RELATION_TYPE = True +JSON_API_FORMAT_TYPES = 'camelize' +JSON_API_PLURALIZE_TYPES = True REST_FRAMEWORK.update({ 'PAGE_SIZE': 1, }) diff --git a/example/tests/integration/test_includes.py b/example/tests/integration/test_includes.py index 4e8c79ce..05c59131 100644 --- a/example/tests/integration/test_includes.py +++ b/example/tests/integration/test_includes.py @@ -28,9 +28,10 @@ def test_included_data_on_detail(single_entry, client): expected_comment_count = single_entry.comment_set.count() assert comment_count == expected_comment_count, 'Detail comment count is incorrect' + def test_dynamic_related_data_is_included(single_entry, entry_factory, client): entry_factory() - response = client.get(reverse("entry-detail", kwargs={'pk': single_entry.pk}) + '?include=suggested') + response = client.get(reverse("entry-detail", kwargs={'pk': single_entry.pk}) + '?include=featured') included = load_json(response.content).get('included') assert [x.get('type') for x in included] == ['entries'], 'Dynamic included types are incorrect' @@ -39,14 +40,74 @@ def test_dynamic_related_data_is_included(single_entry, entry_factory, client): def test_missing_field_not_included(author_bio_factory, author_factory, client): # First author does not have a bio - author = author_factory() + author = author_factory(bio=None) response = client.get(reverse('author-detail', args=[author.pk])+'?include=bio') data = load_json(response.content) assert 'included' not in data # Second author does - bio = author_bio_factory() - response = client.get(reverse('author-detail', args=[bio.author.pk])+'?include=bio') + author = author_factory() + response = client.get(reverse('author-detail', args=[author.pk])+'?include=bio') data = load_json(response.content) assert 'included' in data assert len(data['included']) == 1 - assert data['included'][0]['attributes']['body'] == bio.body + assert data['included'][0]['attributes']['body'] == author.bio.body + + +def test_deep_included_data_on_list(multiple_entries, client): + response = client.get(reverse("entry-list") + '?include=comments,comments.author,' + 'comments.author.bio&page_size=5') + included = load_json(response.content).get('included') + + assert len(load_json(response.content)['data']) == len(multiple_entries), 'Incorrect entry count' + assert [x.get('type') for x in included] == [ + 'authorBios', 'authorBios', 'authors', 'authors', 'comments', 'comments' + ], 'List included types are incorrect' + + comment_count = len([resource for resource in included if resource["type"] == "comments"]) + expected_comment_count = sum([entry.comment_set.count() for entry in multiple_entries]) + assert comment_count == expected_comment_count, 'List comment count is incorrect' + + author_count = len([resource for resource in included if resource["type"] == "authors"]) + expected_author_count = sum( + [entry.comment_set.filter(author__isnull=False).count() for entry in multiple_entries]) + assert author_count == expected_author_count, 'List author count is incorrect' + + author_bio_count = len([resource for resource in included if resource["type"] == "authorBios"]) + expected_author_bio_count = sum([entry.comment_set.filter( + author__bio__isnull=False).count() for entry in multiple_entries]) + assert author_bio_count == expected_author_bio_count, 'List author bio count is incorrect' + + # Also include entry authors + response = client.get(reverse("entry-list") + '?include=authors,comments,comments.author,' + 'comments.author.bio&page_size=5') + included = load_json(response.content).get('included') + + assert len(load_json(response.content)['data']) == len(multiple_entries), 'Incorrect entry count' + assert [x.get('type') for x in included] == [ + 'authorBios', 'authorBios', 'authors', 'authors', 'authors', 'authors', + 'comments', 'comments'], 'List included types are incorrect' + + author_count = len([resource for resource in included if resource["type"] == "authors"]) + expected_author_count = sum( + [entry.authors.count() for entry in multiple_entries] + + [entry.comment_set.filter(author__isnull=False).count() for entry in multiple_entries]) + assert author_count == expected_author_count, 'List author count is incorrect' + + +def test_deep_included_data_on_detail(single_entry, client): + # Same test as in list but also ensures that intermediate resources (here comments' authors) + # are returned along with the leaf nodes + response = client.get(reverse("entry-detail", kwargs={'pk': single_entry.pk}) + + '?include=comments,comments.author.bio') + included = load_json(response.content).get('included') + + assert [x.get('type') for x in included] == ['authorBios', 'authors', 'comments'], \ + 'Detail included types are incorrect' + + comment_count = len([resource for resource in included if resource["type"] == "comments"]) + expected_comment_count = single_entry.comment_set.count() + assert comment_count == expected_comment_count, 'Detail comment count is incorrect' + + author_bio_count = len([resource for resource in included if resource["type"] == "authorBios"]) + expected_author_bio_count = single_entry.comment_set.filter(author__bio__isnull=False).count() + assert author_bio_count == expected_author_bio_count, 'Detail author bio count is incorrect' diff --git a/example/tests/integration/test_meta.py b/example/tests/integration/test_meta.py index af69a910..d854a34b 100644 --- a/example/tests/integration/test_meta.py +++ b/example/tests/integration/test_meta.py @@ -7,7 +7,39 @@ pytestmark = pytest.mark.django_db -def test_top_level_meta(blog, client): +def test_top_level_meta_for_list_view(blog, client): + + expected = { + "data": [{ + "type": "blogs", + "id": "1", + "attributes": { + "name": blog.name + }, + "meta": { + "copyright": datetime.now().year + }, + }], + 'links': { + 'first': 'http://testserver/blogs?page=1', + 'last': 'http://testserver/blogs?page=1', + 'next': None, + 'prev': None + }, + 'meta': { + 'pagination': {'count': 1, 'page': 1, 'pages': 1}, + 'apiDocs': '/docs/api/blogs' + } + } + + response = client.get(reverse("blog-list")) + content_dump = redump_json(response.content) + expected_dump = dump_json(expected) + + assert content_dump == expected_dump + + +def test_top_level_meta_for_detail_view(blog, client): expected = { "data": { diff --git a/example/tests/integration/test_model_resource_name.py b/example/tests/integration/test_model_resource_name.py index 979b55b5..db9f7832 100644 --- a/example/tests/integration/test_model_resource_name.py +++ b/example/tests/integration/test_model_resource_name.py @@ -103,6 +103,11 @@ def test_type_match_on_included_and_inline_with_serializer_resource_name(self, c _check_relationship_and_included_comment_type_are_the_same(client, reverse("entry-list")) + def test_type_match_on_included_and_inline_without_serializer_resource_name(self, client): + serializers.CommentSerializer.Meta.resource_name = None + + _check_relationship_and_included_comment_type_are_the_same(client, reverse("entry-list")) + def test_type_match_on_included_and_inline_with_serializer_resource_name_and_JSONAPIMeta(self, client): models.Comment.__bases__ += (_PatchedModel,) serializers.CommentSerializer.Meta.resource_name = "resource_name_from_serializer" diff --git a/example/tests/integration/test_non_paginated_responses.py b/example/tests/integration/test_non_paginated_responses.py index f68f2b71..de9e3055 100644 --- a/example/tests/integration/test_non_paginated_responses.py +++ b/example/tests/integration/test_non_paginated_responses.py @@ -41,6 +41,9 @@ def test_multiple_entries_no_pagination(multiple_entries, rf): "comments": { "meta": {"count": 1}, "data": [{"type": "comments", "id": "1"}] + }, + "suggested": { + "data": [{"type": "entries", "id": "2"}] } } }, @@ -69,6 +72,9 @@ def test_multiple_entries_no_pagination(multiple_entries, rf): "comments": { "meta": {"count": 1}, "data": [{"type": "comments", "id": "2"}] + }, + "suggested": { + "data": [{"type": "entries", "id": "1"}] } } }, diff --git a/example/tests/integration/test_pagination.py b/example/tests/integration/test_pagination.py index 0cc5e15e..742be523 100644 --- a/example/tests/integration/test_pagination.py +++ b/example/tests/integration/test_pagination.py @@ -35,6 +35,9 @@ def test_pagination_with_single_entry(single_entry, client): "comments": { "meta": {"count": 1}, "data": [{"type": "comments", "id": "1"}] + }, + "suggested": { + "data": [] } } }], diff --git a/example/tests/test_relations.py b/example/tests/test_relations.py index 1fe5b537..adbf4984 100644 --- a/example/tests/test_relations.py +++ b/example/tests/test_relations.py @@ -5,7 +5,7 @@ from . import TestBase from rest_framework_json_api.exceptions import Conflict -from rest_framework_json_api.utils import format_relation_name +from rest_framework_json_api.utils import format_resource_type from example.models import Blog, Entry, Comment, Author from example.serializers import CommentSerializer from rest_framework_json_api.relations import ResourceRelatedField @@ -42,7 +42,7 @@ def test_data_in_correct_format_when_instantiated_with_blog_object(self): serializer = BlogFKSerializer(instance={'blog': self.blog}) expected_data = { - 'type': format_relation_name('Blog'), + 'type': format_resource_type('Blog'), 'id': str(self.blog.id) } @@ -54,7 +54,7 @@ def test_data_in_correct_format_when_instantiated_with_entry_object(self): serializer = EntryFKSerializer(instance={'entry': self.entry}) expected_data = { - 'type': format_relation_name('Entry'), + 'type': format_resource_type('Entry'), 'id': str(self.entry.id) } @@ -65,7 +65,7 @@ def test_data_in_correct_format_when_instantiated_with_entry_object(self): def test_deserialize_primitive_data_blog(self): serializer = BlogFKSerializer(data={ 'blog': { - 'type': format_relation_name('Blog'), + 'type': format_resource_type('Blog'), 'id': str(self.blog.id) } } @@ -90,7 +90,7 @@ def test_validation_fails_for_wrong_type(self): def test_serialize_many_to_many_relation(self): serializer = EntryModelSerializer(instance=self.entry) - type_string = format_relation_name('Author') + type_string = format_resource_type('Author') author_pks = Author.objects.values_list('pk', flat=True) expected_data = [{'type': type_string, 'id': str(pk)} for pk in author_pks] @@ -100,7 +100,7 @@ def test_serialize_many_to_many_relation(self): ) def test_deserialize_many_to_many_relation(self): - type_string = format_relation_name('Author') + type_string = format_resource_type('Author') author_pks = Author.objects.values_list('pk', flat=True) authors = [{'type': type_string, 'id': pk} for pk in author_pks] diff --git a/example/tests/test_serializers.py b/example/tests/test_serializers.py index d0aa7177..df556050 100644 --- a/example/tests/test_serializers.py +++ b/example/tests/test_serializers.py @@ -2,7 +2,7 @@ from django.test import TestCase from django.utils import timezone -from rest_framework_json_api.utils import format_relation_name +from rest_framework_json_api.utils import format_resource_type from rest_framework_json_api.serializers import ResourceIdentifierObjectSerializer from example.models import Blog, Entry, Author @@ -35,20 +35,20 @@ def setUp(self): def test_data_in_correct_format_when_instantiated_with_blog_object(self): serializer = ResourceIdentifierObjectSerializer(instance=self.blog) - expected_data = {'type': format_relation_name('Blog'), 'id': str(self.blog.id)} + expected_data = {'type': format_resource_type('Blog'), 'id': str(self.blog.id)} assert serializer.data == expected_data def test_data_in_correct_format_when_instantiated_with_entry_object(self): serializer = ResourceIdentifierObjectSerializer(instance=self.entry) - expected_data = {'type': format_relation_name('Entry'), 'id': str(self.entry.id)} + expected_data = {'type': format_resource_type('Entry'), 'id': str(self.entry.id)} assert serializer.data == expected_data def test_deserialize_primitive_data_blog(self): initial_data = { - 'type': format_relation_name('Blog'), + 'type': format_resource_type('Blog'), 'id': str(self.blog.id) } serializer = ResourceIdentifierObjectSerializer(data=initial_data, model_class=Blog) @@ -60,14 +60,14 @@ def test_data_in_correct_format_when_instantiated_with_queryset(self): qs = Author.objects.all() serializer = ResourceIdentifierObjectSerializer(instance=qs, many=True) - type_string = format_relation_name('Author') + type_string = format_resource_type('Author') author_pks = Author.objects.values_list('pk', flat=True) expected_data = [{'type': type_string, 'id': str(pk)} for pk in author_pks] assert serializer.data == expected_data def test_deserialize_many(self): - type_string = format_relation_name('Author') + type_string = format_resource_type('Author') author_pks = Author.objects.values_list('pk', flat=True) initial_data = [{'type': type_string, 'id': str(pk)} for pk in author_pks] diff --git a/example/tests/test_views.py b/example/tests/test_views.py index c8221077..a4f4ce75 100644 --- a/example/tests/test_views.py +++ b/example/tests/test_views.py @@ -1,13 +1,18 @@ import json +from django.test import RequestFactory from django.utils import timezone from rest_framework.reverse import reverse from rest_framework.test import APITestCase +from rest_framework.test import force_authenticate -from rest_framework_json_api.utils import format_relation_name +from rest_framework_json_api.utils import format_resource_type from example.models import Blog, Entry, Comment, Author +from .. import views +from . import TestBase + class TestRelationshipView(APITestCase): def setUp(self): @@ -44,7 +49,7 @@ def setUp(self): def test_get_entry_relationship_blog(self): url = reverse('entry-relationships', kwargs={'pk': self.first_entry.id, 'related_field': 'blog'}) response = self.client.get(url) - expected_data = {'type': format_relation_name('Blog'), 'id': str(self.first_entry.blog.id)} + expected_data = {'type': format_resource_type('Blog'), 'id': str(self.first_entry.blog.id)} assert response.data == expected_data @@ -55,8 +60,8 @@ def test_get_entry_relationship_invalid_field(self): def test_get_blog_relationship_entry_set(self): response = self.client.get('/blogs/{}/relationships/entry_set'.format(self.blog.id)) - expected_data = [{'type': format_relation_name('Entry'), 'id': str(self.first_entry.id)}, - {'type': format_relation_name('Entry'), 'id': str(self.second_entry.id)}] + expected_data = [{'type': format_resource_type('Entry'), 'id': str(self.first_entry.id)}, + {'type': format_resource_type('Entry'), 'id': str(self.second_entry.id)}] assert response.data == expected_data @@ -85,14 +90,14 @@ def test_get_to_many_relationship_self_link(self): response = self.client.get(url) expected_data = { 'links': {'self': 'http://testserver/authors/1/relationships/comment_set'}, - 'data': [{'id': str(self.second_comment.id), 'type': format_relation_name('Comment')}] + 'data': [{'id': str(self.second_comment.id), 'type': format_resource_type('Comment')}] } assert json.loads(response.content.decode('utf-8')) == expected_data def test_patch_to_one_relationship(self): url = '/entries/{}/relationships/blog'.format(self.first_entry.id) request_data = { - 'data': {'type': format_relation_name('Blog'), 'id': str(self.other_blog.id)} + 'data': {'type': format_resource_type('Blog'), 'id': str(self.other_blog.id)} } response = self.client.patch(url, data=json.dumps(request_data), content_type='application/vnd.api+json') assert response.status_code == 200, response.content.decode() @@ -103,7 +108,7 @@ def test_patch_to_one_relationship(self): def test_patch_to_many_relationship(self): url = '/blogs/{}/relationships/entry_set'.format(self.first_entry.id) request_data = { - 'data': [{'type': format_relation_name('Entry'), 'id': str(self.first_entry.id)}, ] + 'data': [{'type': format_resource_type('Entry'), 'id': str(self.first_entry.id)}, ] } response = self.client.patch(url, data=json.dumps(request_data), content_type='application/vnd.api+json') assert response.status_code == 200, response.content.decode() @@ -114,7 +119,7 @@ def test_patch_to_many_relationship(self): def test_post_to_one_relationship_should_fail(self): url = '/entries/{}/relationships/blog'.format(self.first_entry.id) request_data = { - 'data': {'type': format_relation_name('Blog'), 'id': str(self.other_blog.id)} + 'data': {'type': format_resource_type('Blog'), 'id': str(self.other_blog.id)} } response = self.client.post(url, data=json.dumps(request_data), content_type='application/vnd.api+json') assert response.status_code == 405, response.content.decode() @@ -122,7 +127,7 @@ def test_post_to_one_relationship_should_fail(self): def test_post_to_many_relationship_with_no_change(self): url = '/entries/{}/relationships/comment_set'.format(self.first_entry.id) request_data = { - 'data': [{'type': format_relation_name('Comment'), 'id': str(self.first_comment.id)}, ] + 'data': [{'type': format_resource_type('Comment'), 'id': str(self.first_comment.id)}, ] } response = self.client.post(url, data=json.dumps(request_data), content_type='application/vnd.api+json') assert response.status_code == 204, response.content.decode() @@ -130,7 +135,7 @@ def test_post_to_many_relationship_with_no_change(self): def test_post_to_many_relationship_with_change(self): url = '/entries/{}/relationships/comment_set'.format(self.first_entry.id) request_data = { - 'data': [{'type': format_relation_name('Comment'), 'id': str(self.second_comment.id)}, ] + 'data': [{'type': format_resource_type('Comment'), 'id': str(self.second_comment.id)}, ] } response = self.client.post(url, data=json.dumps(request_data), content_type='application/vnd.api+json') assert response.status_code == 200, response.content.decode() @@ -140,7 +145,7 @@ def test_post_to_many_relationship_with_change(self): def test_delete_to_one_relationship_should_fail(self): url = '/entries/{}/relationships/blog'.format(self.first_entry.id) request_data = { - 'data': {'type': format_relation_name('Blog'), 'id': str(self.other_blog.id)} + 'data': {'type': format_resource_type('Blog'), 'id': str(self.other_blog.id)} } response = self.client.delete(url, data=json.dumps(request_data), content_type='application/vnd.api+json') assert response.status_code == 405, response.content.decode() @@ -164,7 +169,7 @@ def test_delete_relationship_overriding_with_none(self): def test_delete_to_many_relationship_with_no_change(self): url = '/entries/{}/relationships/comment_set'.format(self.first_entry.id) request_data = { - 'data': [{'type': format_relation_name('Comment'), 'id': str(self.second_comment.id)}, ] + 'data': [{'type': format_resource_type('Comment'), 'id': str(self.second_comment.id)}, ] } response = self.client.delete(url, data=json.dumps(request_data), content_type='application/vnd.api+json') assert response.status_code == 204, response.content.decode() @@ -172,7 +177,7 @@ def test_delete_to_many_relationship_with_no_change(self): def test_delete_one_to_many_relationship_with_not_null_constraint(self): url = '/entries/{}/relationships/comment_set'.format(self.first_entry.id) request_data = { - 'data': [{'type': format_relation_name('Comment'), 'id': str(self.first_comment.id)}, ] + 'data': [{'type': format_resource_type('Comment'), 'id': str(self.first_comment.id)}, ] } response = self.client.delete(url, data=json.dumps(request_data), content_type='application/vnd.api+json') assert response.status_code == 409, response.content.decode() @@ -180,7 +185,37 @@ def test_delete_one_to_many_relationship_with_not_null_constraint(self): def test_delete_to_many_relationship_with_change(self): url = '/authors/{}/relationships/comment_set'.format(self.author.id) request_data = { - 'data': [{'type': format_relation_name('Comment'), 'id': str(self.second_comment.id)}, ] + 'data': [{'type': format_resource_type('Comment'), 'id': str(self.second_comment.id)}, ] } response = self.client.delete(url, data=json.dumps(request_data), content_type='application/vnd.api+json') assert response.status_code == 200, response.content.decode() + + +class TestValidationErrorResponses(TestBase): + def test_if_returns_error_on_empty_post(self): + view = views.BlogViewSet.as_view({'post': 'create'}) + response = self._get_create_response("{}", view) + self.assertEqual(400, response.status_code) + expected = [{'detail': 'Received document does not contain primary data', 'status': '400', 'source': {'pointer': '/data'}}] + self.assertEqual(expected, response.data) + + def test_if_returns_error_on_missing_form_data_post(self): + view = views.BlogViewSet.as_view({'post': 'create'}) + response = self._get_create_response('{"data":{"attributes":{},"type":"blogs"}}', view) + self.assertEqual(400, response.status_code) + expected = [{'status': '400', 'detail': 'This field is required.', 'source': {'pointer': '/data/attributes/name'}}] + self.assertEqual(expected, response.data) + + def test_if_returns_error_on_bad_endpoint_name(self): + view = views.BlogViewSet.as_view({'post': 'create'}) + response = self._get_create_response('{"data":{"attributes":{},"type":"bad"}}', view) + self.assertEqual(409, response.status_code) + expected = [{'detail': "The resource object's type (bad) is not the type that constitute the collection represented by the endpoint (blogs).", 'source': {'pointer': '/data'}, 'status': '409'}] + self.assertEqual(expected, response.data) + + def _get_create_response(self, data, view): + factory = RequestFactory() + request = factory.post('/', data, content_type='application/vnd.api+json') + user = self.create_user('user', 'pass') + force_authenticate(request, user) + return view(request) diff --git a/example/tests/unit/test_pagination.py b/example/tests/unit/test_pagination.py new file mode 100644 index 00000000..b0a08a94 --- /dev/null +++ b/example/tests/unit/test_pagination.py @@ -0,0 +1,79 @@ +from collections import OrderedDict + +from rest_framework.request import Request +from rest_framework.test import APIRequestFactory +from rest_framework.utils.urls import replace_query_param + +from rest_framework_json_api.pagination import LimitOffsetPagination + + +factory = APIRequestFactory() + + +class TestLimitOffset: + """ + Unit tests for `pagination.LimitOffsetPagination`. + """ + + def setup(self): + class ExamplePagination(LimitOffsetPagination): + default_limit = 10 + max_limit = 15 + + self.pagination = ExamplePagination() + self.queryset = range(1, 101) + self.base_url = 'http://testserver/' + + def paginate_queryset(self, request): + return list(self.pagination.paginate_queryset(self.queryset, request)) + + def get_paginated_content(self, queryset): + response = self.pagination.get_paginated_response(queryset) + return response.data + + def get_test_request(self, arguments): + return Request(factory.get('/', arguments)) + + def test_valid_offset_limit(self): + """ + Basic test, assumes offset and limit are given. + """ + offset = 10 + limit = 5 + count = len(self.queryset) + last_offset = count - limit + next_offset = 15 + prev_offset = 5 + + request = self.get_test_request({ + self.pagination.limit_query_param: limit, + self.pagination.offset_query_param: offset + }) + base_url = replace_query_param(self.base_url, self.pagination.limit_query_param, limit) + last_url = replace_query_param(base_url, self.pagination.offset_query_param, last_offset) + first_url = base_url + next_url = replace_query_param(base_url, self.pagination.offset_query_param, next_offset) + prev_url = replace_query_param(base_url, self.pagination.offset_query_param, prev_offset) + queryset = self.paginate_queryset(request) + content = self.get_paginated_content(queryset) + next_offset = offset + limit + + expected_content = { + 'results': list(range(offset + 1, next_offset + 1)), + 'links': OrderedDict([ + ('first', first_url), + ('last', last_url), + ('next', next_url), + ('prev', prev_url), + ]), + 'meta': { + 'pagination': OrderedDict([ + ('count', count), + ('limit', limit), + ('offset', offset), + ]) + } + } + + assert queryset == list(range(offset + 1, next_offset + 1)) + assert content == expected_content diff --git a/example/tests/unit/test_renderer_class_methods.py b/example/tests/unit/test_renderer_class_methods.py index 9671b3d4..61208a32 100644 --- a/example/tests/unit/test_renderer_class_methods.py +++ b/example/tests/unit/test_renderer_class_methods.py @@ -61,38 +61,42 @@ def test_extract_meta(): } assert JSONRenderer.extract_meta(serializer, serializer.data) == expected -def test_extract_root_meta(): - def get_root_meta(obj): - return { - 'foo': 'meta-value' - } - serializer = ResourceSerializer() - serializer.get_root_meta = get_root_meta +class ExtractRootMetaResourceSerializer(ResourceSerializer): + def get_root_meta(self, resource, many): + if many: + return { + 'foo': 'meta-many-value' + } + else: + return { + 'foo': 'meta-value' + } + + +class InvalidExtractRootMetaResourceSerializer(ResourceSerializer): + def get_root_meta(self, resource, many): + return 'not a dict' + + +def test_extract_root_meta(): + serializer = ExtractRootMetaResourceSerializer() expected = { 'foo': 'meta-value', } - assert JSONRenderer.extract_root_meta(serializer, {}, {}) == expected + assert JSONRenderer.extract_root_meta(serializer, {}) == expected def test_extract_root_meta_many(): - def get_root_meta(obj): - return { - 'foo': 'meta-value' - } - - serializer = ResourceSerializer(many=True) - serializer.get_root_meta = get_root_meta + serializer = ExtractRootMetaResourceSerializer(many=True) expected = { - 'foo': 'meta-value' + 'foo': 'meta-many-value' } - assert JSONRenderer.extract_root_meta(serializer, {}, {}) == expected + assert JSONRenderer.extract_root_meta(serializer, {}) == expected def test_extract_root_meta_invalid_meta(): - def get_root_meta(obj): + def get_root_meta(resource, many): return 'not a dict' - serializer = ResourceSerializer() - serializer.get_root_meta = get_root_meta + serializer = InvalidExtractRootMetaResourceSerializer() with pytest.raises(AssertionError) as e_info: - JSONRenderer.extract_root_meta(serializer, {}, {}) - + JSONRenderer.extract_root_meta(serializer, {}) diff --git a/example/tests/unit/test_utils.py b/example/tests/unit/test_utils.py index 14ebd202..92b12010 100644 --- a/example/tests/unit/test_utils.py +++ b/example/tests/unit/test_utils.py @@ -15,6 +15,11 @@ pytestmark = pytest.mark.django_db +class NonModelResourceSerializer(serializers.Serializer): + class Meta: + resource_name = 'users' + + class ResourceSerializer(serializers.ModelSerializer): class Meta: fields = ('username',) @@ -24,11 +29,11 @@ class Meta: def test_get_resource_name(): view = APIView() context = {'view': view} - setattr(settings, 'JSON_API_FORMAT_RELATION_KEYS', None) + setattr(settings, 'JSON_API_FORMAT_TYPES', None) assert 'APIViews' == utils.get_resource_name(context), 'not formatted' context = {'view': view} - setattr(settings, 'JSON_API_FORMAT_RELATION_KEYS', 'dasherize') + setattr(settings, 'JSON_API_FORMAT_TYPES', 'dasherize') assert 'api-views' == utils.get_resource_name(context), 'derived from view' view.model = get_user_model() @@ -51,6 +56,11 @@ def test_get_resource_name(): view.serializer_class.Meta.resource_name = 'rcustom' assert 'rcustom' == utils.get_resource_name(context), 'set on serializer' + view = GenericAPIView() + view.serializer_class = NonModelResourceSerializer + context = {'view': view} + assert 'users' == utils.get_resource_name(context), 'derived from non-model serializer' + def test_format_keys(): underscored = { @@ -81,9 +91,9 @@ def test_format_value(): assert utils.format_value('first-name', 'underscore') == 'first_name' -def test_format_relation_name(): - assert utils.format_relation_name('first_name', 'capitalize') == 'FirstNames' - assert utils.format_relation_name('first_name', 'camelize') == 'firstNames' +def test_format_resource_type(): + assert utils.format_resource_type('first_name', 'capitalize') == 'FirstNames' + assert utils.format_resource_type('first_name', 'camelize') == 'firstNames' class SerializerWithIncludedSerializers(EntrySerializer): diff --git a/example/views.py b/example/views.py index 5982e09e..988cda66 100644 --- a/example/views.py +++ b/example/views.py @@ -1,20 +1,66 @@ +from rest_framework import exceptions from rest_framework import viewsets +import rest_framework.parsers +import rest_framework.renderers +import rest_framework_json_api.metadata +import rest_framework_json_api.parsers +import rest_framework_json_api.renderers from rest_framework_json_api.views import RelationshipView from example.models import Blog, Entry, Author, Comment from example.serializers import ( BlogSerializer, EntrySerializer, AuthorSerializer, CommentSerializer) +from rest_framework_json_api.utils import format_drf_errors + +HTTP_422_UNPROCESSABLE_ENTITY = 422 + class BlogViewSet(viewsets.ModelViewSet): queryset = Blog.objects.all() serializer_class = BlogSerializer +class JsonApiViewSet(viewsets.ModelViewSet): + """ + This is an example on how to configure DRF-jsonapi from + within a class. It allows using DRF-jsonapi alongside + vanilla DRF API views. + """ + parser_classes = [ + rest_framework_json_api.parsers.JSONParser, + rest_framework.parsers.FormParser, + rest_framework.parsers.MultiPartParser, + ] + renderer_classes = [ + rest_framework_json_api.renderers.JSONRenderer, + rest_framework.renderers.BrowsableAPIRenderer, + ] + metadata_class = rest_framework_json_api.metadata.JSONAPIMetadata + + def handle_exception(self, exc): + if isinstance(exc, exceptions.ValidationError): + # some require that validation errors return 422 status + # for example ember-data (isInvalid method on adapter) + exc.status_code = HTTP_422_UNPROCESSABLE_ENTITY + # exception handler can't be set on class so you have to + # override the error response in this method + response = super(JsonApiViewSet, self).handle_exception(exc) + context = self.get_exception_handler_context() + return format_drf_errors(response, context, exc) + + +class BlogCustomViewSet(JsonApiViewSet): + queryset = Blog.objects.all() + serializer_class = BlogSerializer + + class EntryViewSet(viewsets.ModelViewSet): queryset = Entry.objects.all() - serializer_class = EntrySerializer resource_name = 'posts' + def get_serializer_class(self): + return EntrySerializer + class AuthorViewSet(viewsets.ModelViewSet): queryset = Author.objects.all() diff --git a/rest_framework_json_api/__init__.py b/rest_framework_json_api/__init__.py index a13e93bc..31f7af4a 100644 --- a/rest_framework_json_api/__init__.py +++ b/rest_framework_json_api/__init__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- __title__ = 'djangorestframework-jsonapi' -__version__ = '2.0.0-beta.2' +__version__ = '2.0.0' __author__ = '' __license__ = 'MIT' __copyright__ = '' diff --git a/rest_framework_json_api/exceptions.py b/rest_framework_json_api/exceptions.py index 935fecdb..a4a78b74 100644 --- a/rest_framework_json_api/exceptions.py +++ b/rest_framework_json_api/exceptions.py @@ -1,9 +1,16 @@ -import inspect -from django.utils import six, encoding +from django.conf import settings from django.utils.translation import ugettext_lazy as _ from rest_framework import status, exceptions -from rest_framework_json_api.utils import format_value +from rest_framework_json_api import utils +from rest_framework_json_api import renderers + + +def rendered_with_json_api(view): + for renderer_class in getattr(view, 'renderer_classes', []): + if issubclass(renderer_class, renderers.JSONRenderer): + return True + return False def exception_handler(exc, context): @@ -14,67 +21,28 @@ def exception_handler(exc, context): # # Also see: https://github.com/django-json-api/django-rest-framework-json-api/issues/158 from rest_framework.views import exception_handler as drf_exception_handler - response = drf_exception_handler(exc, context) + # Render exception with DRF + response = drf_exception_handler(exc, context) if not response: return response - errors = [] - # handle generic errors. ValidationError('test') in a view for example - if isinstance(response.data, list): - for message in response.data: - errors.append({ - 'detail': message, - 'source': { - 'pointer': '/data', - }, - 'status': encoding.force_text(response.status_code), - }) - # 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(error, six.string_types): - 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({ - 'detail': error, - 'source': { - 'pointer': pointer, - }, - 'status': encoding.force_text(response.status_code), - }) - elif isinstance(error, list): - for message in error: - errors.append({ - 'detail': message, - 'source': { - 'pointer': pointer, - }, - 'status': encoding.force_text(response.status_code), - }) - else: - errors.append({ - 'detail': error, - 'source': { - 'pointer': pointer, - }, - 'status': encoding.force_text(response.status_code), - }) + # Use regular DRF format if not rendered by DRF JSON API and not uniform + is_json_api_view = rendered_with_json_api(context['view']) + is_uniform = getattr(settings, 'JSON_API_UNIFORM_EXCEPTIONS', False) + if not is_json_api_view and not is_uniform: + return response + + # Convert to DRF JSON API error format + response = utils.format_drf_errors(response, context, exc) + # Add top-level 'errors' object when not rendered by DRF JSON API + if not is_json_api_view: + response.data = utils.format_errors(response.data) - context['view'].resource_name = 'errors' - response.data = errors return response class Conflict(exceptions.APIException): status_code = status.HTTP_409_CONFLICT default_detail = _('Conflict.') - diff --git a/rest_framework_json_api/pagination.py b/rest_framework_json_api/pagination.py index e8b52401..092a4450 100644 --- a/rest_framework_json_api/pagination.py +++ b/rest_framework_json_api/pagination.py @@ -4,8 +4,8 @@ from collections import OrderedDict from rest_framework import serializers from rest_framework.views import Response -from rest_framework.pagination import PageNumberPagination -from rest_framework.templatetags.rest_framework import replace_query_param +from rest_framework.pagination import PageNumberPagination, LimitOffsetPagination +from rest_framework.utils.urls import remove_query_param, replace_query_param class PageNumberPagination(PageNumberPagination): @@ -47,3 +47,52 @@ def get_paginated_response(self, data): ('prev', self.build_link(previous)) ]) }) + + +class LimitOffsetPagination(LimitOffsetPagination): + """ + A limit/offset based style. For example: + http://api.example.org/accounts/?page[limit]=100 + http://api.example.org/accounts/?page[offset]=400&page[limit]=100 + """ + limit_query_param = 'page[limit]' + offset_query_param = 'page[offset]' + + def get_last_link(self): + if self.count == 0: + return None + + url = self.request.build_absolute_uri() + url = replace_query_param(url, self.limit_query_param, self.limit) + + offset = self.count - self.limit + + if offset <= 0: + return remove_query_param(url, self.offset_query_param) + + return replace_query_param(url, self.offset_query_param, offset) + + def get_first_link(self): + if self.count == 0: + return None + + url = self.request.build_absolute_uri() + return remove_query_param(url, self.offset_query_param) + + def get_paginated_response(self, data): + return Response({ + 'results': data, + 'meta': { + 'pagination': OrderedDict([ + ('count', self.count), + ('limit', self.limit), + ('offset', self.offset), + ]) + }, + 'links': OrderedDict([ + ('first', self.get_first_link()), + ('last', self.get_last_link()), + ('next', self.get_next_link()), + ('prev', self.get_previous_link()) + ]) + }) \ No newline at end of file diff --git a/rest_framework_json_api/relations.py b/rest_framework_json_api/relations.py index b7ccce36..0e6594d5 100644 --- a/rest_framework_json_api/relations.py +++ b/rest_framework_json_api/relations.py @@ -3,6 +3,7 @@ from rest_framework.fields import MISSING_ERROR_MESSAGE from rest_framework.relations import * from django.utils.translation import ugettext_lazy as _ +from django.db.models.query import QuerySet from rest_framework_json_api.exceptions import Conflict from rest_framework_json_api.utils import Hyperlink, \ @@ -168,11 +169,50 @@ def choices(self): ]) + class SerializerMethodResourceRelatedField(ResourceRelatedField): + """ + Allows us to use serializer method RelatedFields + with return querysets + """ + def __new__(cls, *args, **kwargs): + """ + We override this because getting serializer methods + fails at the base class when many=True + """ + if kwargs.pop('many', False): + return cls.many_init(*args, **kwargs) + return super(ResourceRelatedField, cls).__new__(cls, *args, **kwargs) + + def __init__(self, child_relation=None, *args, **kwargs): + # DRF 3.1 doesn't expect the `many` kwarg + kwargs.pop('many', None) + model = kwargs.pop('model', None) + if model: + self.model = model + super(SerializerMethodResourceRelatedField, self).__init__(child_relation, *args, **kwargs) + + @classmethod + def many_init(cls, *args, **kwargs): + list_kwargs = {'child_relation': cls(*args, **kwargs)} + for key in kwargs.keys(): + if key in ('model',) + MANY_RELATION_KWARGS: + list_kwargs[key] = kwargs[key] + return SerializerMethodResourceRelatedField(**list_kwargs) + def get_attribute(self, instance): # check for a source fn defined on the serializer instead of the model if self.source and hasattr(self.parent, self.source): serializer_method = getattr(self.parent, self.source) if hasattr(serializer_method, '__call__'): return serializer_method(instance) - return super(ResourceRelatedField, self).get_attribute(instance) + return super(SerializerMethodResourceRelatedField, self).get_attribute(instance) + + def to_representation(self, value): + if isinstance(value, QuerySet): + base = super(SerializerMethodResourceRelatedField, self) + return [base.to_representation(x) for x in value] + return super(SerializerMethodResourceRelatedField, self).to_representation(value) + + def get_links(self, obj=None, lookup_field='pk'): + return OrderedDict() diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index a8e852cf..0f33b69c 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -43,6 +43,9 @@ def extract_attributes(fields, resource): # ID is always provided in the root of JSON API so remove it from attributes if field_name == 'id': continue + # don't output a key for write only fields + if fields[field_name].write_only: + continue # Skip fields with relations if isinstance(field, (relations.RelatedField, relations.ManyRelatedField, BaseSerializer)): continue @@ -208,7 +211,7 @@ def extract_relationships(fields, resource, resource_instance): if isinstance(field, ModelSerializer): relation_model = field.Meta.model - relation_type = utils.format_relation_name(relation_model.__name__) + relation_type = utils.format_type(relation_model.__name__) data.update({ field_name: { @@ -248,7 +251,9 @@ def extract_included(fields, resource, resource_instance, included_resources): included_resources.remove(field_name) except ValueError: # Skip fields not in requested included resources - continue + # If no child field, directly continue with the next field + if field_name not in [node.split('.')[0] for node in included_resources]: + continue try: relation_instance_or_manager = getattr(resource_instance, field_name) @@ -290,9 +295,13 @@ def extract_included(fields, resource, resource_instance, included_resources): for position in range(len(serializer_data)): serializer_resource = serializer_data[position] nested_resource_instance = relation_queryset[position] + resource_type = ( + relation_type or + utils.get_resource_type_from_instance(nested_resource_instance) + ) included_data.append( JSONRenderer.build_json_resource_obj( - serializer_fields, serializer_resource, nested_resource_instance, relation_type + serializer_fields, serializer_resource, nested_resource_instance, resource_type ) ) included_data.extend( @@ -336,13 +345,18 @@ def extract_meta(serializer, resource): return data @staticmethod - def extract_root_meta(serializer, resource, meta): + def extract_root_meta(serializer, resource): + many = False + if hasattr(serializer, 'child'): + many = True + serializer = serializer.child + + data = {} if getattr(serializer, 'get_root_meta', None): - root_meta = serializer.get_root_meta(resource) - if root_meta: - assert isinstance(root_meta, dict), 'get_root_meta must return a dict' - meta.update(root_meta) - return meta + json_api_meta = serializer.get_root_meta(resource, many) + assert isinstance(json_api_meta, dict), 'get_root_meta must return a dict' + data.update(json_api_meta) + return data @staticmethod def build_json_resource_obj(fields, resource, resource_instance, resource_name): @@ -374,11 +388,8 @@ def render_relationship_view(self, data, accepted_media_type=None, renderer_cont ) def render_errors(self, data, accepted_media_type=None, renderer_context=None): - # Get the resource name. - if len(data) > 1 and isinstance(data, list): - data.sort(key=lambda x: x.get('source', {}).get('pointer', '')) return super(JSONRenderer, self).render( - {'errors': data}, accepted_media_type, renderer_context + utils.format_errors(data), accepted_media_type, renderer_context ) def render(self, data, accepted_media_type=None, renderer_context=None): @@ -410,6 +421,7 @@ def render(self, data, accepted_media_type=None, renderer_context=None): else: included_resources = list() + json_api_data = data json_api_included = list() # initialize json_api_meta with pagination meta or an empty dict json_api_meta = data.get('meta', {}) if isinstance(data, dict) else {} @@ -419,51 +431,44 @@ def render(self, data, accepted_media_type=None, renderer_context=None): else: serializer_data = data - if hasattr(serializer_data, 'serializer') and getattr(serializer_data.serializer, 'many', False): - # The below is not true for non-paginated responses - # and isinstance(data, dict): - - # If detail view then json api spec expects dict, otherwise a list - # - http://jsonapi.org/format/#document-top-level - # The `results` key may be missing if unpaginated or an OPTIONS request + serializer = getattr(serializer_data, 'serializer', None) - resource_serializer = serializer_data.serializer + if serializer is not None: # Get the serializer fields - fields = utils.get_serializer_fields(resource_serializer) + fields = utils.get_serializer_fields(serializer) - json_api_data = list() - for position in range(len(serializer_data)): - resource = serializer_data[position] # Get current resource - resource_instance = resource_serializer.instance[position] # Get current instance + # Extract root meta for any type of serializer + json_api_meta.update(self.extract_root_meta(serializer, serializer_data)) - json_resource_obj = self.build_json_resource_obj(fields, resource, resource_instance, resource_name) - meta = self.extract_meta(resource_serializer, resource) - if meta: - json_resource_obj.update({'meta': utils.format_keys(meta)}) - json_api_meta = self.extract_root_meta(resource_serializer, resource, json_api_meta) - json_api_data.append(json_resource_obj) + if getattr(serializer, 'many', False): + json_api_data = list() - included = self.extract_included(fields, resource, resource_instance, included_resources) - if included: - json_api_included.extend(included) - else: - # Check if data contains a serializer - if hasattr(data, 'serializer'): - fields = utils.get_serializer_fields(data.serializer) - resource_instance = data.serializer.instance - json_api_data = self.build_json_resource_obj(fields, data, resource_instance, resource_name) + for position in range(len(serializer_data)): + resource = serializer_data[position] # Get current resource + resource_instance = serializer.instance[position] # Get current instance + + json_resource_obj = self.build_json_resource_obj(fields, resource, resource_instance, resource_name) + meta = self.extract_meta(serializer, resource) + if meta: + json_resource_obj.update({'meta': utils.format_keys(meta)}) + json_api_data.append(json_resource_obj) - meta = self.extract_meta(data.serializer, data) + included = self.extract_included(fields, resource, resource_instance, included_resources) + if included: + json_api_included.extend(included) + else: + resource_instance = serializer.instance + json_api_data = self.build_json_resource_obj(fields, serializer_data, resource_instance, resource_name) + + meta = self.extract_meta(serializer, serializer_data) if meta: json_api_data.update({'meta': utils.format_keys(meta)}) - json_api_meta = self.extract_root_meta(data.serializer, data, json_api_meta) - included = self.extract_included(fields, data, resource_instance, included_resources) + included = self.extract_included(fields, serializer_data, resource_instance, included_resources) if included: json_api_included.extend(included) - else: - json_api_data = data + # Make sure we render data in a specific order render_data = OrderedDict() diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index f68d984e..953c4437 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -84,7 +84,7 @@ def validate_path(serializer_class, field_path, path): ) ) if len(field_path) > 1: - new_included_field_path = field_path[-1:] + new_included_field_path = field_path[1:] # We go down one level in the path validate_path(this_included_serializer, new_included_field_path, path) @@ -94,7 +94,7 @@ def validate_path(serializer_class, field_path, path): included_resources = include_resources_param.split(',') for included_field_name in included_resources: included_field_path = included_field_name.split('.') - this_serializer_class = view.serializer_class + this_serializer_class = view.get_serializer_class() # lets validate the current path validate_path(this_serializer_class, included_field_path, included_field_name) diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index d8a4e67a..261640c6 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -2,14 +2,18 @@ Utils. """ import copy +import warnings from collections import OrderedDict +import inspect import inflection from django.conf import settings +from django.utils import encoding from django.utils import six from django.utils.module_loading import import_string as import_class_from_dotted_path from django.utils.translation import ugettext_lazy as _ from rest_framework.exceptions import APIException +from rest_framework import exceptions try: from rest_framework.serializers import ManyRelatedField @@ -59,7 +63,7 @@ def get_resource_name(context): return resource_name # the name was calculated automatically from the view > pluralize and format - resource_name = format_relation_name(resource_name) + resource_name = format_resource_type(resource_name) return resource_name @@ -137,10 +141,18 @@ def format_value(value, format_type=None): def format_relation_name(value, format_type=None): + warnings.warn("The 'format_relation_name' function has been renamed 'format_resource_type' and the settings are now 'JSON_API_FORMAT_TYPES' and 'JSON_API_PLURALIZE_TYPES'") if format_type is None: - format_type = getattr(settings, 'JSON_API_FORMAT_RELATION_KEYS', False) + format_type = getattr(settings, 'JSON_API_FORMAT_RELATION_KEYS', None) + pluralize = getattr(settings, 'JSON_API_PLURALIZE_RELATION_TYPE', None) + return format_resource_type(value, format_type, pluralize) - pluralize = getattr(settings, 'JSON_API_PLURALIZE_RELATION_TYPE', False) +def format_resource_type(value, format_type=None, pluralize=None): + if format_type is None: + format_type = getattr(settings, 'JSON_API_FORMAT_TYPES', False) + + if pluralize is None: + pluralize = getattr(settings, 'JSON_API_PLURALIZE_TYPES', False) if format_type: # format_type will never be None here so we can use format_value @@ -198,7 +210,7 @@ def get_resource_type_from_model(model): return getattr( json_api_meta, 'resource_name', - format_relation_name(model.__name__)) + format_resource_type(model.__name__)) def get_resource_type_from_queryset(qs): @@ -214,10 +226,10 @@ def get_resource_type_from_manager(manager): def get_resource_type_from_serializer(serializer): - return getattr( - serializer.Meta, - 'resource_name', - get_resource_type_from_model(serializer.Meta.model)) + if hasattr(serializer.Meta, 'resource_name'): + return serializer.Meta.resource_name + else: + return get_resource_type_from_model(serializer.Meta.model) def get_included_serializers(serializer): @@ -249,3 +261,65 @@ def __new__(self, url, name): return ret is_hyperlink = True + + +def format_drf_errors(response, context, exc): + errors = [] + # handle generic errors. ValidationError('test') in a view for example + if isinstance(response.data, list): + for message in response.data: + errors.append({ + 'detail': message, + 'source': { + 'pointer': '/data', + }, + 'status': encoding.force_text(response.status_code), + }) + # 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(error, six.string_types): + 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({ + 'detail': error, + 'source': { + 'pointer': pointer, + }, + 'status': encoding.force_text(response.status_code), + }) + elif isinstance(error, list): + for message in error: + errors.append({ + 'detail': message, + 'source': { + 'pointer': pointer, + }, + 'status': encoding.force_text(response.status_code), + }) + else: + errors.append({ + 'detail': error, + 'source': { + 'pointer': pointer, + }, + 'status': encoding.force_text(response.status_code), + }) + + context['view'].resource_name = 'errors' + response.data = errors + + return response + + +def format_errors(data): + if len(data) > 1 and isinstance(data, list): + data.sort(key=lambda x: x.get('source', {}).get('pointer', '')) + return {'errors': data} diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index a43fad89..4b6e631a 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -19,6 +19,7 @@ class RelationshipView(generics.GenericAPIView): serializer_class = ResourceIdentifierObjectSerializer self_link_view_name = None related_link_view_name = None + field_name_mapping = {} def get_serializer_class(self): if getattr(self, 'action', False) is None: @@ -96,7 +97,7 @@ def patch(self, request, *args, **kwargs): related_model_class = related_instance_or_manager.__class__ serializer = self.get_serializer(data=request.data, model_class=related_model_class) serializer.is_valid(raise_exception=True) - setattr(parent_obj, kwargs['related_field'], serializer.validated_data) + setattr(parent_obj, self.get_related_field_name(), serializer.validated_data) parent_obj.save() result_serializer = self._instantiate_serializer(related_instance_or_manager) return Response(result_serializer.data) @@ -138,10 +139,16 @@ def delete(self, request, *args, **kwargs): def get_related_instance(self): try: - return getattr(self.get_object(), self.kwargs['related_field']) + return getattr(self.get_object(), self.get_related_field_name()) except AttributeError: raise NotFound + def get_related_field_name(self): + field_name = self.kwargs['related_field'] + if field_name in self.field_name_mapping: + return self.field_name_mapping[field_name] + return field_name + def _instantiate_serializer(self, instance): if isinstance(instance, Model) or instance is None: return self.get_serializer(instance=instance) @@ -153,7 +160,7 @@ def _instantiate_serializer(self, instance): def get_resource_name(self): if not hasattr(self, '_resource_name'): - instance = getattr(self.get_object(), self.kwargs['related_field']) + instance = getattr(self.get_object(), self.get_related_field_name()) self._resource_name = get_resource_type_from_instance(instance) return self._resource_name diff --git a/setup.py b/setup.py index 344f8b54..384207a5 100644 --- a/setup.py +++ b/setup.py @@ -76,7 +76,7 @@ def get_package_data(package): 'inflection>=0.3.0' ], classifiers=[ - 'Development Status :: 4 - Beta', + 'Development Status :: 5 - Production/Stable', 'Environment :: Web Environment', 'Framework :: Django', 'Intended Audience :: Developers', diff --git a/tox.ini b/tox.ini index fbb33a91..9ee8fafb 100644 --- a/tox.ini +++ b/tox.ini @@ -14,7 +14,9 @@ deps = drf33: djangorestframework>=3.3,<3.4 -r{toxinidir}/requirements-development.txt -setenv= DJANGO_SETTINGS_MODULE=example.settings.test +setenv = + PYTHONPATH = {toxinidir} + DJANGO_SETTINGS_MODULE=example.settings.test commands = py.test --basetemp={envtmpdir} From bb4ea992e930ae844021caed29fb7555bbd8c189 Mon Sep 17 00:00:00 2001 From: Anton-Shutik Date: Fri, 14 Oct 2016 17:43:05 +0300 Subject: [PATCH 3/3] Use cls instead of SerializerMethodResourceRelatedField --- rest_framework_json_api/relations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rest_framework_json_api/relations.py b/rest_framework_json_api/relations.py index 9762bc74..7d52bb0f 100644 --- a/rest_framework_json_api/relations.py +++ b/rest_framework_json_api/relations.py @@ -200,7 +200,7 @@ def many_init(cls, *args, **kwargs): for key in kwargs.keys(): if key in ('model',) + MANY_RELATION_KWARGS: list_kwargs[key] = kwargs[key] - return SerializerMethodResourceRelatedField(**list_kwargs) + return cls(**list_kwargs) def get_attribute(self, instance): # check for a source fn defined on the serializer instead of the model