From 4bfed362b0b07725cf241bf648e544981ca85119 Mon Sep 17 00:00:00 2001 From: Jerel Unruh Date: Fri, 11 Dec 2015 17:36:46 -0600 Subject: [PATCH 1/5] Added tests for data meta objects --- example/tests/integration/test_meta.py | 29 +++++++++++++++++++ .../test_non_paginated_responses.py | 6 ++++ example/tests/integration/test_pagination.py | 3 ++ 3 files changed, 38 insertions(+) create mode 100644 example/tests/integration/test_meta.py diff --git a/example/tests/integration/test_meta.py b/example/tests/integration/test_meta.py new file mode 100644 index 00000000..62866ade --- /dev/null +++ b/example/tests/integration/test_meta.py @@ -0,0 +1,29 @@ +from datetime import datetime +from django.core.urlresolvers import reverse + +import pytest +from example.tests.utils import dump_json, redump_json + +pytestmark = pytest.mark.django_db + + +def test_top_level_data_meta(blog, client): + + expected = { + "data": { + "type": "blogs", + "id": "1", + "attributes": { + "name": blog.name + }, + "meta": { + "copyright": datetime.now().year + }, + }, + } + + response = client.get(reverse("blog-detail", kwargs={'pk': blog.pk})) + content_dump = redump_json(response.content) + expected_dump = dump_json(expected) + + assert content_dump == expected_dump diff --git a/example/tests/integration/test_non_paginated_responses.py b/example/tests/integration/test_non_paginated_responses.py index 4a2684f4..f68f2b71 100644 --- a/example/tests/integration/test_non_paginated_responses.py +++ b/example/tests/integration/test_non_paginated_responses.py @@ -26,6 +26,9 @@ def test_multiple_entries_no_pagination(multiple_entries, rf): "pubDate": None, "modDate": None }, + "meta": { + "bodyFormat": "text" + }, "relationships": { "blog": { @@ -51,6 +54,9 @@ def test_multiple_entries_no_pagination(multiple_entries, rf): "pubDate": None, "modDate": None }, + "meta": { + "bodyFormat": "text" + }, "relationships": { "blog": { diff --git a/example/tests/integration/test_pagination.py b/example/tests/integration/test_pagination.py index 793205a5..0cc5e15e 100644 --- a/example/tests/integration/test_pagination.py +++ b/example/tests/integration/test_pagination.py @@ -20,6 +20,9 @@ def test_pagination_with_single_entry(single_entry, client): "pubDate": None, "modDate": None }, + "meta": { + "bodyFormat": "text" + }, "relationships": { "blog": { From a763ace0f31217f2bd8e18f472dbb5f274d92e08 Mon Sep 17 00:00:00 2001 From: Jerel Unruh Date: Fri, 11 Dec 2015 17:38:10 -0600 Subject: [PATCH 2/5] Added support for meta objects in serializers and list serializers --- example/serializers.py | 17 ++++++++++ rest_framework_json_api/renderers.py | 45 +++++++++++++++++++++++--- rest_framework_json_api/serializers.py | 21 ++++++++++++ rest_framework_json_api/utils.py | 15 +++++++-- 4 files changed, 92 insertions(+), 6 deletions(-) diff --git a/example/serializers.py b/example/serializers.py index c6b243a1..6481cf3d 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -1,12 +1,24 @@ +from datetime import datetime from rest_framework_json_api import serializers, relations from example.models import Blog, Entry, Author, Comment class BlogSerializer(serializers.ModelSerializer): + copyright = serializers.SerializerMethodField() + + def get_copyright(self, obj): + return datetime.now().year + + def get_root_meta(self, obj): + return { + 'api_docs': '/docs/api/blogs' + } + class Meta: model = Blog fields = ('name', ) + meta_fields = ('copyright',) class EntrySerializer(serializers.ModelSerializer): @@ -24,6 +36,7 @@ def __init__(self, *args, **kwargs): 'suggested': 'example.serializers.EntrySerializer', } + body_format = serializers.SerializerMethodField() comments = relations.ResourceRelatedField( source='comment_set', many=True, read_only=True) suggested = relations.SerializerMethodResourceRelatedField( @@ -32,10 +45,14 @@ def __init__(self, *args, **kwargs): def get_suggested(self, obj): return Entry.objects.exclude(pk=obj.pk).first() + def get_body_format(self, obj): + return 'text' + class Meta: model = Entry fields = ('blog', 'headline', 'body_text', 'pub_date', 'mod_date', 'authors', 'comments', 'suggested',) + meta_fields = ('body_format',) class AuthorSerializer(serializers.ModelSerializer): diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index 23833bc7..742d1a3e 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -319,6 +319,29 @@ def extract_included(fields, resource, resource_instance, included_resources): return utils.format_keys(included_data) + @staticmethod + def extract_meta(serializer, resource): + if hasattr(serializer, 'child'): + meta = getattr(serializer.child, 'Meta', None) + else: + meta = getattr(serializer, 'Meta', None) + meta_fields = getattr(meta, 'meta_fields', {}) + data = OrderedDict() + for field_name in meta_fields: + data.update({ + field_name: resource.get(field_name) + }) + return data + + @staticmethod + def extract_root_meta(serializer, resource, meta): + 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 + @staticmethod def build_json_resource_obj(fields, resource, resource_instance, resource_name): resource_data = [ @@ -386,6 +409,8 @@ def render(self, data, accepted_media_type=None, renderer_context=None): included_resources = list() 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 {} if data and 'results' in data: serializer_data = data["results"] @@ -409,8 +434,14 @@ def render(self, data, accepted_media_type=None, renderer_context=None): for position in range(len(serializer_data)): resource = serializer_data[position] # Get current resource resource_instance = resource_serializer.instance[position] # Get current instance - json_api_data.append( - self.build_json_resource_obj(fields, resource, resource_instance, resource_name)) + + 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) + included = self.extract_included(fields, resource, resource_instance, included_resources) if included: json_api_included.extend(included) @@ -420,6 +451,12 @@ def render(self, data, accepted_media_type=None, renderer_context=None): 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) + + meta = self.extract_meta(data.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) if included: json_api_included.extend(included) @@ -452,8 +489,8 @@ def render(self, data, accepted_media_type=None, renderer_context=None): # Sort the items by type then by id render_data['included'] = sorted(unique_compound_documents, key=lambda item: (item['type'], item['id'])) - if isinstance(data, dict) and data.get('meta'): - render_data['meta'] = data.get('meta') + if json_api_meta: + render_data['meta'] = utils.format_keys(json_api_meta) return super(JSONRenderer, self).render( render_data, accepted_media_type, renderer_context diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index 8fd78292..1adf7d83 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -136,3 +136,24 @@ class ModelSerializer(IncludedResourcesValidationMixin, SparseFieldsetsMixin, Mo * A mixin class to enable validation of included resources is included """ serializer_related_field = ResourceRelatedField + + def __init__(self, *args, **kwargs): + meta_fields = getattr(self.Meta, 'meta_fields', []) + # we add meta_fields to fields so they will be serialized like usual + self.Meta.fields = tuple(tuple(self.Meta.fields) + tuple(meta_fields)) + super(ModelSerializer, self).__init__(*args, **kwargs) + + def get_field_names(self, declared_fields, info): + """ + We override the parent to omit explicity defined meta fields (such + as SerializerMethodFields) from the list of declared fields + """ + meta_fields = getattr(self.Meta, 'meta_fields', None) + + declared = OrderedDict() + for field_name in declared_fields.keys(): + field = declared_fields[field_name] + if field_name not in meta_fields: + declared[field_name] = field + return super(ModelSerializer, self).get_field_names(declared, info) + diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index ed01b739..da88b5e8 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -65,11 +65,22 @@ def get_resource_name(context): def get_serializer_fields(serializer): + fields = None if hasattr(serializer, 'child'): - return getattr(serializer.child, 'fields') + fields = getattr(serializer.child, 'fields') + meta = getattr(serializer.child, 'Meta', None) if hasattr(serializer, 'fields'): - return getattr(serializer, 'fields') + fields = getattr(serializer, 'fields') + meta = getattr(serializer, 'Meta', None) + if fields: + meta_fields = getattr(meta, 'meta_fields', {}) + for field in meta_fields: + try: + fields.pop(field) + except KeyError: + pass + return fields def format_keys(obj, format_type=None): """ From 7d43eb84a2b6306ccdcf3ba958e5c45750d3bf6d Mon Sep 17 00:00:00 2001 From: Jerel Unruh Date: Mon, 14 Dec 2015 14:06:44 -0600 Subject: [PATCH 3/5] Added tests for extract_meta functions and top level meta objects --- example/tests/integration/test_meta.py | 5 +- .../tests/unit/test_renderer_class_methods.py | 48 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/example/tests/integration/test_meta.py b/example/tests/integration/test_meta.py index 62866ade..af69a910 100644 --- a/example/tests/integration/test_meta.py +++ b/example/tests/integration/test_meta.py @@ -7,7 +7,7 @@ pytestmark = pytest.mark.django_db -def test_top_level_data_meta(blog, client): +def test_top_level_meta(blog, client): expected = { "data": { @@ -20,6 +20,9 @@ def test_top_level_data_meta(blog, client): "copyright": datetime.now().year }, }, + "meta": { + "apiDocs": "/docs/api/blogs" + }, } response = client.get(reverse("blog-detail", kwargs={'pk': blog.pk})) diff --git a/example/tests/unit/test_renderer_class_methods.py b/example/tests/unit/test_renderer_class_methods.py index d76b86f3..9671b3d4 100644 --- a/example/tests/unit/test_renderer_class_methods.py +++ b/example/tests/unit/test_renderer_class_methods.py @@ -7,8 +7,12 @@ pytestmark = pytest.mark.django_db class ResourceSerializer(serializers.ModelSerializer): + version = serializers.SerializerMethodField() + def get_version(self, obj): + return '1.0.0' class Meta: fields = ('username',) + meta_fields = ('version',) model = get_user_model() @@ -48,3 +52,47 @@ def test_extract_attributes(): assert sorted(JSONRenderer.extract_attributes(fields, resource)) == sorted(expected), 'Regular fields should be extracted' assert sorted(JSONRenderer.extract_attributes(fields, {})) == sorted( {'username': ''}), 'Should not extract read_only fields on empty serializer' + +def test_extract_meta(): + serializer = ResourceSerializer(data={'username': 'jerel', 'version':'1.0.0'}) + serializer.is_valid() + expected = { + 'version': '1.0.0', + } + 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 + expected = { + 'foo': 'meta-value', + } + 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 + expected = { + 'foo': 'meta-value' + } + assert JSONRenderer.extract_root_meta(serializer, {}, {}) == expected + +def test_extract_root_meta_invalid_meta(): + def get_root_meta(obj): + return 'not a dict' + + serializer = ResourceSerializer() + serializer.get_root_meta = get_root_meta + with pytest.raises(AssertionError) as e_info: + JSONRenderer.extract_root_meta(serializer, {}, {}) + From 3b930777d9182f850a0f9dc3747898cd7647733c Mon Sep 17 00:00:00 2001 From: Jerel Unruh Date: Mon, 14 Dec 2015 15:57:55 -0600 Subject: [PATCH 4/5] Added documentation for meta usage and for renderer class --- docs/api.md | 31 +++++++++++++++++++++++++++++++ docs/usage.md | 20 +++++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/docs/api.md b/docs/api.md index 77e64ba8..2e625d9d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -7,3 +7,34 @@ Add this mixin to a view to override `get_queryset` to automatically filter records by `ids[]=1&ids[]=2` in URL query params. +## rest_framework_json_api.renderers.JSONRenderer + +The `JSONRenderer` exposes a number of methods that you may override if you need +highly custom rendering control. + +#### extract_attributes + +`extract_attributes(fields, resource)` + +Builds the `attributes` object of the JSON API resource object. + +#### extract_relationships(fields, resource, resource_instance) + +Builds the `relationships` top level object based on related serializers. + +#### extract_included(fields, resource, resource_instance, included_resources) + +Adds related data to the top level `included` key when the request includes `?include=example,example_field2` + +#### extract_meta(serializer, resource) + +Gathers the data from serializer fields specified in `meta_fields` and adds it to the `meta` object. + +#### extract_root_meta(serializer, resource, meta) + +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 10c968ad..32c6d183 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -229,10 +229,28 @@ When set to pluralize: Both `JSON_API_PLURALIZE_RELATION_TYPE` and `JSON_API_FORMAT_RELATION_KEYS` can be combined to achieve different results. +### Meta + +You may add metadata to the rendered json in two different ways: `meta_fields` and `get_root_meta`. + +On any `rest_framework_json_api.serializers.ModelSerializer` you may add a `meta_fields` +property to the `Meta` class. This behaves in the same manner as the default +`fields` property and will cause `SerializerMethodFields` or model values to be +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) + } +``` +to the serializer. It must return a dict and will be merged with the existing top level `meta`. + From 73130e3d08eda6f85d84e2947efc01484d4a78ef Mon Sep 17 00:00:00 2001 From: Jerel Unruh Date: Tue, 15 Dec 2015 13:31:02 -0600 Subject: [PATCH 5/5] Fixed case where a missing `meta_field` value would error --- rest_framework_json_api/renderers.py | 2 +- rest_framework_json_api/serializers.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index 742d1a3e..dd63c6ae 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -325,7 +325,7 @@ def extract_meta(serializer, resource): meta = getattr(serializer.child, 'Meta', None) else: meta = getattr(serializer, 'Meta', None) - meta_fields = getattr(meta, 'meta_fields', {}) + meta_fields = getattr(meta, 'meta_fields', []) data = OrderedDict() for field_name in meta_fields: data.update({ diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index 1adf7d83..94e01c0d 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -148,10 +148,10 @@ def get_field_names(self, declared_fields, info): We override the parent to omit explicity defined meta fields (such as SerializerMethodFields) from the list of declared fields """ - meta_fields = getattr(self.Meta, 'meta_fields', None) + meta_fields = getattr(self.Meta, 'meta_fields', []) declared = OrderedDict() - for field_name in declared_fields.keys(): + for field_name in set(declared_fields.keys()): field = declared_fields[field_name] if field_name not in meta_fields: declared[field_name] = field