├── .bumpversion.cfg ├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.rst ├── __init__.py ├── requirements.txt ├── rest_framework_cj ├── __init__.py ├── fields.py └── renderers.py ├── runtests ├── __init__.py └── runtests.py ├── setup.cfg ├── setup.py ├── testapp ├── __init__.py ├── models.py └── tests │ ├── __init__.py │ ├── settings.py │ └── test_renderers.py └── tox.ini /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.0.1_dev_2 3 | files = setup.py 4 | 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .tox 3 | build 4 | dist 5 | djangorestframework*.egg 6 | djangorestframework*.egg-info 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: "2.7" 3 | env: 4 | - TOXENV=py3.4-django1.7 5 | - TOXENV=py3.3-django1.7 6 | - TOXENV=py3.2-django1.7 7 | - TOXENV=py2.7-django1.7 8 | - TOXENV=py3.4-django1.6 9 | - TOXENV=py3.3-django1.6 10 | - TOXENV=py3.2-django1.6 11 | - TOXENV=py2.7-django1.6 12 | - TOXENV=py2.6-django1.6 13 | - TOXENV=py3.4-django1.5 14 | - TOXENV=py3.3-django1.5 15 | - TOXENV=py3.2-django1.5 16 | - TOXENV=py2.7-django1.5 17 | - TOXENV=py2.6-django1.5 18 | - TOXENV=py2.7-django1.4 19 | - TOXENV=py2.6-django1.4 20 | - TOXENV=py2.7-django1.3 21 | - TOXENV=py2.6-django1.3 22 | install: 23 | - pip install -r requirements.txt 24 | script: tox 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Advisory Board Company. All rights reserved. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.rst LICENSE 2 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ======================================= 2 | Django Rest Framework - Collection+JSON 3 | ======================================= 4 | 5 | .. image:: https://travis-ci.org/advisory/django-rest-framework-collection-json.svg?branch=master 6 | :target: https://travis-ci.org/advisory/django-rest-framework-collection-json 7 | 8 | This library adds support for the Collection+JSON hypermedia format to Django Rest Framework. For more information on Collection+JSON see the `official Collection+JSON documentation `_. 9 | 10 | Installation 11 | ============ 12 | 13 | Install django-rest-framework-collection-json with pip:: 14 | 15 | pip install django-rest-framework-collection-json 16 | 17 | 18 | Usage 19 | ===== 20 | 21 | To enable the Collection+JSON renderer, either add it as a default renderer in your django settings file:: 22 | 23 | 'DEFAULT_RENDERER_CLASSES': ( 24 | 'rest_framework_cj.renderers.CollectionJsonRenderer', 25 | ) 26 | 27 | 28 | or explicitly set the renderer on your view:: 29 | 30 | class MyViewSet(ReadOnlyModelViewSet): 31 | renderer_classes = (CollectionJsonRenderer, ) 32 | 33 | Renderer Behavior 34 | ================= 35 | 36 | The Collection+JSON renderer will do it's best to support the built in Django Rest Framework Serializers and Views. However, the renderer is designed to work with Django Rest Framework's hyperlinked views/serializers. 37 | 38 | Given a simple model and an associated view/serializer:: 39 | 40 | class Dummy(Model): 41 | name = CharField(max_length='100') 42 | 43 | class DummyHyperlinkedModelSerializer(HyperlinkedModelSerializer): 44 | class Meta(object): 45 | model = Dummy 46 | fields = ('url', 'name', ) 47 | 48 | class DummyReadOnlyModelViewSet(ReadOnlyModelViewSet): 49 | renderer_classes = (CollectionJsonRenderer, ) 50 | queryset = Dummy.objects.all() 51 | serializer_class = DummyHyperlinkedModelSerializer 52 | 53 | If you register the view as follows:: 54 | 55 | router = DefaultRouter() 56 | router.register('dummy', DummyReadOnlyModelViewSet) 57 | urlpatterns = patterns( 58 | '', 59 | (r'^rest-api/', include(router.urls)), 60 | ) 61 | 62 | Navigating to the url /rest-api/dummy/ will generate a collection+JSON containing serialized dummy objects in it's items array.:: 63 | 64 | "items": [ 65 | { 66 | "href": "http://foobar.com/rest-api/dummy/1"/, 67 | "data": [ 68 | { 69 | "name": "name", 70 | "value": "foo" 71 | }, 72 | ] 73 | }, 74 | { 75 | "href": "http://foobar.com/rest-api/dummy/2/", 76 | "data": [ 77 | { 78 | "name": "name", 79 | "value": "bar" 80 | }, 81 | ] 82 | } 83 | ] 84 | 85 | Foreign key/Many to Many relationships will be rendered in an item's links array:: 86 | 87 | children = ManyToManyField('Child') 88 | 89 | "links": [ 90 | { 91 | "href": "http://foobar.com/rest-api/child/1/", 92 | "rel": "children" 93 | }, 94 | { 95 | "href": "http://foobar.com/rest-api/child/2/", 96 | "rel": "children" 97 | }, 98 | ] 99 | 100 | The renderer will also recognize the default router and provide links its resources:: 101 | 102 | { 103 | "collection": { 104 | "href": "http://foobar.com/rest-api/", 105 | "items": [], 106 | "version": "1.0", 107 | "links": [ 108 | { 109 | "href": "http://foobar.com/rest-api/dummy/", 110 | "rel": "dummy" 111 | }, 112 | ] 113 | } 114 | } 115 | 116 | Link Fields 117 | =========== 118 | 119 | Django Rest Framework Colleciton+JSON also includes a new LinkField class for linking to arbitrary resources.:: 120 | 121 | class DummyHyperlinkedModelSerializer(HyperlinkedModelSerializer): 122 | related_link = LinkField('get_related_link') 123 | 124 | class Meta(object): 125 | model = Dummy 126 | fields = ('url', 'name', 'related_link') 127 | 128 | def get_related_link(self, obj): 129 | return 'http://something-relavent.com/' 130 | 131 | "items": [ 132 | { 133 | "href": "http://foobar.com/rest-api/dummy/1"/, 134 | "data": [ 135 | { 136 | "name": "name", 137 | "value": "foo" 138 | }, 139 | ], 140 | "links": [ 141 | { 142 | "rel": 'related_link', 143 | "href": 'http://something-relavent.com', 144 | } 145 | ] 146 | }, 147 | ] 148 | 149 | Unit Testing 150 | ============ 151 | 152 | You can run the unit tests against your current environment by running:: 153 | 154 | $ python setup.py test 155 | 156 | You can also use tox:: 157 | 158 | $ tox 159 | 160 | The build environments in the tox configuration are designed to match the builds supported by Django Rest Framework. 161 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eabglobal/django-rest-framework-collection-json/7fb04c39e16b5f8674285969646853278138f8e2/__init__.py -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | collection-json 3 | django 4 | ipdb 5 | ipython 6 | pytest 7 | pytest-xdist 8 | tox 9 | six 10 | -------------------------------------------------------------------------------- /rest_framework_cj/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eabglobal/django-rest-framework-collection-json/7fb04c39e16b5f8674285969646853278138f8e2/rest_framework_cj/__init__.py -------------------------------------------------------------------------------- /rest_framework_cj/fields.py: -------------------------------------------------------------------------------- 1 | from rest_framework.fields import SerializerMethodField 2 | 3 | 4 | class LinkField(SerializerMethodField): 5 | def __init__(self, method_name, *args, **kwargs): 6 | self.method_name = method_name 7 | super(LinkField, self).__init__(method_name, *args, **kwargs) 8 | -------------------------------------------------------------------------------- /rest_framework_cj/renderers.py: -------------------------------------------------------------------------------- 1 | from rest_framework.relations import ( 2 | HyperlinkedRelatedField, 3 | HyperlinkedIdentityField, 4 | ) 5 | from rest_framework.serializers import HyperlinkedModelSerializer 6 | from rest_framework.renderers import JSONRenderer 7 | 8 | from .fields import LinkField 9 | 10 | 11 | class CollectionJsonRenderer(JSONRenderer): 12 | media_type = 'application/vnd.collection+json' 13 | format = 'collection+json' 14 | 15 | def _transform_field(self, key, value): 16 | return {'name': key, 'value': value} 17 | 18 | def _get_related_fields(self, fields, id_field): 19 | return [k for (k, v) in fields 20 | if k != id_field 21 | and (isinstance(v, HyperlinkedRelatedField) 22 | or isinstance(v, HyperlinkedIdentityField) 23 | or isinstance(v, LinkField))] 24 | 25 | def _simple_transform_item(self, item): 26 | data = [self._transform_field(k, v) for (k, v) in item.items()] 27 | return {'data': data} 28 | 29 | def _get_id_field(self, serializer): 30 | if isinstance(serializer, HyperlinkedModelSerializer): 31 | return serializer.opts.url_field_name 32 | else: 33 | return None 34 | 35 | def _get_item_field_links(self, field_name, item): 36 | data = item[field_name] 37 | 38 | if data is None: 39 | return [] 40 | elif isinstance(data, list): 41 | return [self._make_link(field_name, x) for x in data] 42 | else: 43 | return [self._make_link(field_name, data)] 44 | 45 | def _transform_item(self, serializer, item): 46 | fields = serializer.fields.items() 47 | id_field = self._get_id_field(serializer) 48 | related_fields = self._get_related_fields(fields, id_field) 49 | 50 | data = [self._transform_field(k, item[k]) 51 | for k in item.keys() 52 | if k != id_field and k not in related_fields] 53 | result = {'data': data} 54 | 55 | if id_field: 56 | result['href'] = item[id_field] 57 | 58 | links = [] 59 | for x in related_fields: 60 | links.extend(self._get_item_field_links(x, item)) 61 | 62 | if links: 63 | result['links'] = links 64 | 65 | return result 66 | 67 | def _transform_items(self, view, data): 68 | if isinstance(data, dict): 69 | data = [data] 70 | 71 | if hasattr(view, 'get_serializer'): 72 | serializer = view.get_serializer() 73 | return map(lambda x: self._transform_item(serializer, x), data) 74 | else: 75 | return map(self._simple_transform_item, data) 76 | 77 | def _is_paginated(self, data): 78 | pagination_keys = ('next', 'previous', 'results') 79 | return all(k in data for k in pagination_keys) 80 | 81 | def _get_pagination_links(self, data): 82 | results = [] 83 | if data.get('next', None): 84 | results.append(self._make_link('next', data['next'])) 85 | 86 | if data.get('previous', None): 87 | results.append(self._make_link('previous', data['previous'])) 88 | 89 | return results 90 | 91 | def _get_items_from_paginated_data(self, data): 92 | return data.get('results') 93 | 94 | def _make_link(self, rel, href): 95 | return {'rel': rel, 'href': href} 96 | 97 | def _get_error(self, data): 98 | return { 99 | 'error': { 100 | 'message': data['detail'] 101 | } 102 | } 103 | 104 | def _get_items_and_links(self, view, data): 105 | # ------------------------------------------ 106 | # ______ ___ ______ 107 | # )_ \ '-,) / _( 108 | # )_ \_//_/ _( 109 | # )___ ___( 110 | # )) 111 | # (( 112 | # ``- 113 | # HC SVNT DRACONES (et debitum technica) 114 | # ------------------------------------------ 115 | # This lookup of the Api Root string isn't 116 | # the right long-term approach. Even if we 117 | # looked it up properly from the default 118 | # router, we would still need to handle 119 | # custom routers. Works okay for now. 120 | # ------------------------------------------ 121 | if view.get_view_name() == 'Api Root': 122 | links = [self._make_link(key, data[key]) for key in data.keys()] 123 | items = [] 124 | else: 125 | links = [] 126 | if self._is_paginated(data): 127 | links.extend(self._get_pagination_links(data)) 128 | data = self._get_items_from_paginated_data(data) 129 | 130 | items = self._transform_items(view, data) 131 | 132 | return { 133 | 'items': items, 134 | 'links': links, 135 | } 136 | 137 | def _transform_data(self, request, response, view, data): 138 | collection = { 139 | "version": "1.0", 140 | "href": self.get_href(request), 141 | } 142 | 143 | if response.exception: 144 | collection.update(self._get_error(data)) 145 | else: 146 | collection.update(self._get_items_and_links(view, data)) 147 | 148 | return {'collection': collection} 149 | 150 | def get_href(self, request): 151 | return request.build_absolute_uri() 152 | 153 | def render(self, data, media_type=None, renderer_context=None): 154 | request = renderer_context['request'] 155 | view = renderer_context['view'] 156 | response = renderer_context['response'] 157 | 158 | if data: 159 | data = self._transform_data(request, response, view, data) 160 | 161 | return super(CollectionJsonRenderer, self).render(data, media_type, 162 | renderer_context) 163 | -------------------------------------------------------------------------------- /runtests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eabglobal/django-rest-framework-collection-json/7fb04c39e16b5f8674285969646853278138f8e2/runtests/__init__.py -------------------------------------------------------------------------------- /runtests/runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Useful tool to run the test suite and generate a coverage report. 4 | Shamelessly adapted from https://github.com/tomchristie/django-rest-framework/blob/master/rest_framework/runtests/runcoverage.py 5 | """ 6 | 7 | import os 8 | import sys 9 | 10 | # fix sys path so we don't need to setup PYTHONPATH 11 | #sys.path.append(os.path.join(os.path.dirname(__file__), "..")) 12 | os.environ['DJANGO_SETTINGS_MODULE'] = 'testapp.tests.settings' 13 | 14 | 15 | def main(): 16 | import django 17 | if django.VERSION[0] >= 1 and django.VERSION[1] >= 7: 18 | django.setup() 19 | 20 | from django.conf import settings 21 | from django.test.utils import get_runner 22 | 23 | TestRunner = get_runner(settings) 24 | 25 | test_runner = TestRunner() 26 | failures = test_runner.run_tests(['testapp']) 27 | 28 | sys.exit(failures) 29 | 30 | if __name__ == '__main__': 31 | main() 32 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file=README.rst 3 | 4 | [wheel] 5 | universal = 1 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup, find_packages 3 | 4 | 5 | def read(*paths): 6 | with open(os.path.join(*paths), 'r') as f: 7 | return f.read() 8 | 9 | setup( 10 | name='djangorestframework-collection-json', 11 | version='0.0.1_dev_2', 12 | description='Collection+JSON support for Django REST Framework', 13 | long_description=read('README.rst'), 14 | author='Advisory Board Company', 15 | author_email='chris@chrismarinos.com', 16 | url='https://github.com/advisory/django-rest-framework-collection-json', 17 | license='MIT', 18 | packages=find_packages(exclude=['tests*']), 19 | install_requires=['djangorestframework'], 20 | classifiers=[ 21 | 'Development Status :: 4 - Beta', 22 | 'Environment :: Web Environment', 23 | 'Framework :: Django', 24 | 'Intended Audience :: Developers', 25 | 'License :: OSI Approved :: MIT License', 26 | 'Operating System :: OS Independent', 27 | 'Programming Language :: Python', 28 | 'Programming Language :: Python :: 3', 29 | 'Topic :: Internet :: WWW/HTTP', 30 | ], 31 | include_package_data=True, 32 | test_suite='runtests.runtests.main', 33 | ) 34 | -------------------------------------------------------------------------------- /testapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eabglobal/django-rest-framework-collection-json/7fb04c39e16b5f8674285969646853278138f8e2/testapp/__init__.py -------------------------------------------------------------------------------- /testapp/models.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Model, CharField, ForeignKey, ManyToManyField 2 | 3 | 4 | class Moron(Model): 5 | name = CharField(max_length='100') 6 | 7 | 8 | class Idiot(Model): 9 | name = CharField(max_length='100') 10 | 11 | 12 | class Dummy(Model): 13 | name = CharField(max_length='100') 14 | moron = ForeignKey('Moron') 15 | idiots = ManyToManyField('Idiot') 16 | 17 | class Simple(Model): 18 | name = CharField(max_length='100') 19 | -------------------------------------------------------------------------------- /testapp/tests/__init__.py: -------------------------------------------------------------------------------- 1 | import django 2 | from django.utils import unittest 3 | 4 | 5 | if django.VERSION[0] <= 1 and django.VERSION[1] <= 5: 6 | def suite(): 7 | return unittest.TestLoader().discover("testapp", pattern="test_*.py") 8 | -------------------------------------------------------------------------------- /testapp/tests/settings.py: -------------------------------------------------------------------------------- 1 | # shamelesly adapted from 2 | # https://github.com/tomchristie/django-rest-framework/blob/master/rest_framework/runtests/settings.py 3 | 4 | DEBUG = True 5 | TEMPLATE_DEBUG = DEBUG 6 | DEBUG_PROPAGATE_EXCEPTIONS = True 7 | 8 | ALLOWED_HOSTS = ['*'] 9 | 10 | ADMINS = ( 11 | # ('Your Name', 'your_email@domain.com'), 12 | ) 13 | 14 | MANAGERS = ADMINS 15 | 16 | DATABASES = { 17 | 'default': { 18 | 'ENGINE': 'django.db.backends.sqlite3', # Add 'postgresql_psycopg2', 'postgresql', 'mysql', 'sqlite3' or 'oracle'. 19 | 'NAME': 'sqlite.db', # Or path to database file if using sqlite3. 20 | 'USER': '', # Not used with sqlite3. 21 | 'PASSWORD': '', # Not used with sqlite3. 22 | 'HOST': '', # Set to empty string for localhost. Not used with sqlite3. 23 | 'PORT': '', # Set to empty string for default. Not used with sqlite3. 24 | } 25 | } 26 | 27 | CACHES = { 28 | 'default': { 29 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 30 | } 31 | } 32 | 33 | # Local time zone for this installation. Choices can be found here: 34 | # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name 35 | # although not all choices may be available on all operating systems. 36 | # On Unix systems, a value of None will cause Django to use the same 37 | # timezone as the operating system. 38 | # If running in a Windows environment this must be set to the same as your 39 | # system time zone. 40 | TIME_ZONE = 'Europe/London' 41 | 42 | # Language code for this installation. All choices can be found here: 43 | # http://www.i18nguy.com/unicode/language-identifiers.html 44 | LANGUAGE_CODE = 'en-uk' 45 | 46 | SITE_ID = 1 47 | 48 | # If you set this to False, Django will make some optimizations so as not 49 | # to load the internationalization machinery. 50 | USE_I18N = True 51 | 52 | # If you set this to False, Django will not format dates, numbers and 53 | # calendars according to the current locale 54 | USE_L10N = True 55 | 56 | # Absolute filesystem path to the directory that will hold user-uploaded files. 57 | # Example: "/home/media/media.lawrence.com/" 58 | MEDIA_ROOT = '' 59 | 60 | # URL that handles the media served from MEDIA_ROOT. Make sure to use a 61 | # trailing slash if there is a path component (optional in other cases). 62 | # Examples: "http://media.lawrence.com", "http://example.com/media/" 63 | MEDIA_URL = '' 64 | 65 | # Make this unique, and don't share it with anybody. 66 | SECRET_KEY = 'u@x-aj9(hoh#rb-^ymf#g2jx_hp0vj7u5#b@ag1n^seu9e!%cy' 67 | 68 | # List of callables that know how to import templates from various sources. 69 | TEMPLATE_LOADERS = ( 70 | 'django.template.loaders.filesystem.Loader', 71 | 'django.template.loaders.app_directories.Loader', 72 | # 'django.template.loaders.eggs.Loader', 73 | ) 74 | 75 | MIDDLEWARE_CLASSES = ( 76 | 'django.middleware.common.CommonMiddleware', 77 | 'django.contrib.sessions.middleware.SessionMiddleware', 78 | 'django.middleware.csrf.CsrfViewMiddleware', 79 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 80 | 'django.contrib.messages.middleware.MessageMiddleware', 81 | ) 82 | 83 | ROOT_URLCONF = 'urls' 84 | 85 | TEMPLATE_DIRS = ( 86 | # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates". 87 | # Always use forward slashes, even on Windows. 88 | # Don't forget to use absolute paths, not relative paths. 89 | ) 90 | 91 | INSTALLED_APPS = ( 92 | 'django.contrib.auth', 93 | 'django.contrib.contenttypes', 94 | 'django.contrib.sessions', 95 | 'django.contrib.sites', 96 | 'django.contrib.messages', 97 | # Uncomment the next line to enable the admin: 98 | # 'django.contrib.admin', 99 | # Uncomment the next line to enable admin documentation: 100 | # 'django.contrib.admindocs', 101 | 'rest_framework', 102 | 'rest_framework.authtoken', 103 | 'testapp', 104 | ) 105 | 106 | STATIC_URL = '/static/' 107 | 108 | PASSWORD_HASHERS = ( 109 | 'django.contrib.auth.hashers.SHA1PasswordHasher', 110 | 'django.contrib.auth.hashers.PBKDF2PasswordHasher', 111 | 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', 112 | 'django.contrib.auth.hashers.BCryptPasswordHasher', 113 | 'django.contrib.auth.hashers.MD5PasswordHasher', 114 | 'django.contrib.auth.hashers.CryptPasswordHasher', 115 | ) 116 | 117 | AUTH_USER_MODEL = 'auth.User' 118 | 119 | import django 120 | 121 | if django.VERSION < (1, 3): 122 | INSTALLED_APPS += ('staticfiles',) 123 | -------------------------------------------------------------------------------- /testapp/tests/test_renderers.py: -------------------------------------------------------------------------------- 1 | from six.moves.urllib.parse import urljoin 2 | 3 | import django 4 | if django.VERSION[0] == 1 and django.VERSION[1] == 3: 5 | from django.conf.urls.defaults import patterns, include 6 | else: 7 | from django.conf.urls import patterns, include 8 | 9 | from django.test import TestCase 10 | 11 | from collection_json import Collection 12 | from rest_framework import status 13 | from rest_framework.exceptions import ParseError 14 | from rest_framework.relations import HyperlinkedIdentityField 15 | from rest_framework.response import Response 16 | from rest_framework.routers import DefaultRouter 17 | from rest_framework.serializers import ( 18 | HyperlinkedModelSerializer, ModelSerializer 19 | ) 20 | from rest_framework.status import HTTP_204_NO_CONTENT 21 | from rest_framework.views import APIView 22 | from rest_framework.viewsets import ReadOnlyModelViewSet 23 | 24 | from rest_framework_cj.renderers import CollectionJsonRenderer 25 | from rest_framework_cj.fields import LinkField 26 | 27 | from testapp.models import Dummy, Idiot, Moron, Simple 28 | 29 | 30 | class MoronHyperlinkedModelSerializer(HyperlinkedModelSerializer): 31 | class Meta(object): 32 | model = Moron 33 | fields = ('url', 'name') 34 | 35 | 36 | class MoronReadOnlyModelViewSet(ReadOnlyModelViewSet): 37 | renderer_classes = (CollectionJsonRenderer, ) 38 | queryset = Moron.objects.all() 39 | serializer_class = MoronHyperlinkedModelSerializer 40 | 41 | 42 | class IdiotHyperlinkedModelSerializer(HyperlinkedModelSerializer): 43 | class Meta(object): 44 | model = Idiot 45 | fields = ('url', 'name') 46 | 47 | 48 | class IdiotReadOnlyModelViewSet(ReadOnlyModelViewSet): 49 | renderer_classes = (CollectionJsonRenderer, ) 50 | queryset = Idiot.objects.all() 51 | serializer_class = IdiotHyperlinkedModelSerializer 52 | 53 | 54 | class DummyHyperlinkedModelSerializer(HyperlinkedModelSerializer): 55 | other_stuff = LinkField('get_other_link') 56 | empty_link = LinkField('get_empty_link') 57 | some_link = HyperlinkedIdentityField(view_name='moron-detail') 58 | 59 | class Meta(object): 60 | model = Dummy 61 | fields = ('url', 'name', 'moron', 'idiots', 'other_stuff', 'some_link', 'empty_link') 62 | 63 | def get_other_link(self, obj): 64 | return 'http://other-stuff.com/' 65 | 66 | def get_empty_link(self, obj): 67 | return None 68 | 69 | 70 | class DummyReadOnlyModelViewSet(ReadOnlyModelViewSet): 71 | renderer_classes = (CollectionJsonRenderer, ) 72 | queryset = Dummy.objects.all() 73 | serializer_class = DummyHyperlinkedModelSerializer 74 | 75 | 76 | class NoSerializerView(APIView): 77 | renderer_classes = (CollectionJsonRenderer, ) 78 | 79 | def get(self, request): 80 | return Response({'foo': '1'}) 81 | 82 | 83 | class SimpleGetTest(TestCase): 84 | urls = 'testapp.tests.test_renderers' 85 | endpoint = '' 86 | 87 | def setUp(self): 88 | self.response = self.client.get(self.endpoint) 89 | self.collection = Collection.from_json(self.response.content.decode('utf8')) 90 | 91 | 92 | def create_models(): 93 | bob = Moron.objects.create(name='Bob LawLaw') 94 | dummy = Dummy.objects.create(name='Yolo McSwaggerson', moron=bob) 95 | dummy.idiots.add(Idiot.objects.create(name='frick')) 96 | dummy.idiots.add(Idiot.objects.create(name='frack')) 97 | 98 | 99 | class TestCollectionJsonRenderer(SimpleGetTest): 100 | endpoint = '/rest-api/dummy/' 101 | 102 | def setUp(self): 103 | create_models() 104 | super(TestCollectionJsonRenderer, self).setUp() 105 | 106 | def test_it_has_the_right_response_code(self): 107 | self.assertEqual(self.response.status_code, status.HTTP_200_OK) 108 | 109 | def test_it_has_the_right_content_type(self): 110 | content_type = self.response['Content-Type'] 111 | self.assertEqual(content_type, 'application/vnd.collection+json') 112 | 113 | def test_it_has_the_version_number(self): 114 | self.assertEqual(self.collection.version, '1.0') 115 | 116 | def test_it_has_an_href(self): 117 | href = self.collection.href 118 | self.assertEqual(href, 'http://testserver/rest-api/dummy/') 119 | 120 | def get_dummy(self): 121 | return self.collection.items[0] 122 | 123 | def test_the_dummy_item_has_an_href(self): 124 | href = self.get_dummy().href 125 | self.assertEqual(href, 'http://testserver/rest-api/dummy/1/') 126 | 127 | def test_the_dummy_item_contains_name(self): 128 | name = self.get_dummy().data.find('name')[0].value 129 | self.assertEqual(name, 'Yolo McSwaggerson') 130 | 131 | def get_dummy_link(self, rel): 132 | links = self.get_dummy()['links'] 133 | return next(x for x in links if x['rel'] == rel) 134 | 135 | def test_the_dummy_item_links_to_child_elements(self): 136 | href = self.get_dummy().links.find(rel='moron')[0].href 137 | self.assertEqual(href, 'http://testserver/rest-api/moron/1/') 138 | 139 | def test_link_fields_are_rendered_as_links(self): 140 | href = self.get_dummy().links.find(rel='other_stuff')[0].href 141 | self.assertEqual(href, 'http://other-stuff.com/') 142 | 143 | def test_empty_link_fields_are_not_rendered_as_links(self): 144 | links = self.get_dummy().links.find(rel='empty_link') 145 | self.assertEqual(len(links), 0) 146 | 147 | def test_attribute_links_are_rendered_as_links(self): 148 | href = self.get_dummy().links.find(rel='some_link')[0].href 149 | self.assertEqual(href, 'http://testserver/rest-api/moron/1/') 150 | 151 | def test_many_to_many_relationships_are_rendered_as_links(self): 152 | idiots = self.get_dummy().links.find(rel='idiots') 153 | self.assertEqual(idiots[0].href, 'http://testserver/rest-api/idiot/1/') 154 | self.assertEqual(idiots[1].href, 'http://testserver/rest-api/idiot/2/') 155 | 156 | 157 | class TestNoSerializerViews(SimpleGetTest): 158 | endpoint = '/rest-api/no-serializer/' 159 | 160 | def setUp(self): 161 | create_models() 162 | super(TestNoSerializerViews, self).setUp() 163 | 164 | def test_views_without_a_serializer_work(self): 165 | value = self.collection.items[0].data.find('foo')[0].value 166 | self.assertEqual(value, '1') 167 | 168 | 169 | class SimpleModelSerializer(ModelSerializer): 170 | 171 | class Meta(object): 172 | model = Dummy 173 | fields = ('name', ) 174 | 175 | 176 | class SimpleViewSet(ReadOnlyModelViewSet): 177 | renderer_classes = (CollectionJsonRenderer, ) 178 | queryset = Simple.objects.all() 179 | serializer_class = SimpleModelSerializer 180 | 181 | 182 | class TestNormalModels(SimpleGetTest): 183 | endpoint = '/rest-api/normal-model/' 184 | 185 | def setUp(self): 186 | Simple.objects.create(name='Foobar Baz') 187 | super(TestNormalModels, self).setUp() 188 | 189 | def test_items_dont_have_a_href(self): 190 | href_count = len(self.collection.items[0].find(name='href')) 191 | self.assertEqual(href_count, 0) 192 | 193 | 194 | class PaginatedDataView(APIView): 195 | renderer_classes = (CollectionJsonRenderer, ) 196 | 197 | def get(self, request): 198 | return Response({ 199 | 'next': 'http://test.com/colleciton/next', 200 | 'previous': 'http://test.com/colleciton/previous', 201 | 'results': [{'foo': 1}], 202 | }) 203 | 204 | 205 | class TestCollectionJsonRendererPagination(SimpleGetTest): 206 | endpoint = '/rest-api/paginated/' 207 | 208 | def test_paginated_views_display_data(self): 209 | foo = self.collection.items[0].find(name='foo')[0] 210 | self.assertEqual(foo.value, 1) 211 | 212 | def test_paginated_views_display_next(self): 213 | next_link = self.collection.links.find(rel='next')[0] 214 | self.assertEqual(next_link.href, 'http://test.com/colleciton/next') 215 | 216 | def test_paginated_views_display_previous(self): 217 | next_link = self.collection.links.find(rel='previous')[0] 218 | self.assertEqual(next_link.href, 'http://test.com/colleciton/previous') 219 | 220 | 221 | class NonePaginatedDataView(APIView): 222 | renderer_classes = (CollectionJsonRenderer, ) 223 | 224 | def get(self, request): 225 | return Response({ 226 | 'next': None, 227 | 'previous': None, 228 | 'results': [{'foo': 1}], 229 | }) 230 | 231 | 232 | class TestCollectionJsonRendererPaginationWithNone(SimpleGetTest): 233 | endpoint = '/rest-api/none-paginated/' 234 | 235 | def test_paginated_view_does_not_display_next(self): 236 | self.assertEqual(len(self.collection.links.find(rel='next')), 0) 237 | 238 | def test_paginated_view_does_not_display_previous(self): 239 | self.assertEqual(len(self.collection.links.find(rel='previous')), 0) 240 | 241 | 242 | class ParseErrorView(APIView): 243 | renderer_classes = (CollectionJsonRenderer, ) 244 | 245 | def get(self, request): 246 | raise ParseError('lol nice one') 247 | 248 | 249 | class TestErrorHandling(SimpleGetTest): 250 | endpoint = '/rest-api/parse-error/' 251 | 252 | def test_errors_are_reported(self): 253 | self.assertEqual(self.collection.error.message, 'lol nice one') 254 | 255 | 256 | class UrlRewriteRenderer(CollectionJsonRenderer): 257 | def get_href(self, request): 258 | return urljoin('http://rewritten.com', request.path) 259 | 260 | 261 | class UrlRewriteView(APIView): 262 | renderer_classes = (UrlRewriteRenderer, ) 263 | 264 | def get(self, request): 265 | return Response({'foo': 'bar'}) 266 | 267 | 268 | class TestUrlRewrite(SimpleGetTest): 269 | endpoint = '/rest-api/url-rewrite/' 270 | 271 | def test_the_href_url_can_be_rewritten(self): 272 | rewritten_url = "http://rewritten.com/rest-api/url-rewrite/" 273 | self.assertEqual(self.collection.href, rewritten_url) 274 | 275 | 276 | class EmptyView(APIView): 277 | renderer_classes = (CollectionJsonRenderer, ) 278 | 279 | def get(self, request): 280 | return Response(status=HTTP_204_NO_CONTENT) 281 | 282 | 283 | class TestEmpty(TestCase): 284 | urls = 'testapp.tests.test_renderers' 285 | 286 | def test_empty_content_works(self): 287 | response = self.client.get('/rest-api/empty/') 288 | self.assertEqual(response.status_code, HTTP_204_NO_CONTENT) 289 | self.assertEqual(response.content.decode('utf8'), '') 290 | 291 | 292 | router = DefaultRouter() 293 | router.register('dummy', DummyReadOnlyModelViewSet) 294 | router.register('moron', MoronReadOnlyModelViewSet) 295 | router.register('idiot', IdiotReadOnlyModelViewSet) 296 | router.register('normal-model', SimpleViewSet) 297 | urlpatterns = patterns( 298 | '', 299 | (r'^rest-api/', include(router.urls)), 300 | (r'^rest-api/no-serializer/', NoSerializerView.as_view()), 301 | (r'^rest-api/paginated/', PaginatedDataView.as_view()), 302 | (r'^rest-api/none-paginated/', NonePaginatedDataView.as_view()), 303 | (r'^rest-api/parse-error/', ParseErrorView.as_view()), 304 | (r'^rest-api/url-rewrite/', UrlRewriteView.as_view()), 305 | (r'^rest-api/empty/', EmptyView.as_view()), 306 | ) 307 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | downloadcache = {toxworkdir}/cache/ 3 | envlist = 4 | py3.4-django1.7,py3.3-django1.7,py3.2-django1.7,py2.7-django1.7, 5 | py3.4-django1.6,py3.3-django1.6,py3.2-django1.6,py2.7-django1.6,py2.6-django1.6, 6 | py3.4-django1.5,py3.3-django1.5,py3.2-django1.5,py2.7-django1.5,py2.6-django1.5, 7 | py2.7-django1.4,py2.6-django1.4, 8 | py2.7-django1.3,py2.6-django1.3 9 | 10 | [testenv] 11 | commands = {envpython} setup.py test 12 | 13 | [testenv:py3.4-django1.7] 14 | basepython = python3.4 15 | deps = https://www.djangoproject.com/download/1.7c2/tarball/ 16 | django-filter==0.7 17 | defusedxml==0.3 18 | Pillow==2.3.0 19 | collection-json 20 | djangorestframework 21 | pytest 22 | six 23 | 24 | [testenv:py3.3-django1.7] 25 | basepython = python3.3 26 | deps = https://www.djangoproject.com/download/1.7c2/tarball/ 27 | django-filter==0.7 28 | defusedxml==0.3 29 | Pillow==2.3.0 30 | collection-json 31 | djangorestframework 32 | pytest 33 | six 34 | 35 | [testenv:py3.2-django1.7] 36 | basepython = python3.2 37 | deps = https://www.djangoproject.com/download/1.7c2/tarball/ 38 | django-filter==0.7 39 | defusedxml==0.3 40 | Pillow==2.3.0 41 | collection-json 42 | djangorestframework 43 | pytest 44 | six 45 | 46 | [testenv:py2.7-django1.7] 47 | basepython = python2.7 48 | deps = https://www.djangoproject.com/download/1.7c2/tarball/ 49 | django-filter==0.7 50 | defusedxml==0.3 51 | Pillow==2.3.0 52 | collection-json 53 | djangorestframework 54 | pytest 55 | six 56 | 57 | [testenv:py3.4-django1.6] 58 | basepython = python3.4 59 | deps = Django==1.6.3 60 | django-filter==0.7 61 | defusedxml==0.3 62 | Pillow==2.3.0 63 | collection-json 64 | djangorestframework 65 | pytest 66 | six 67 | 68 | [testenv:py3.3-django1.6] 69 | basepython = python3.3 70 | deps = Django==1.6.3 71 | django-filter==0.7 72 | defusedxml==0.3 73 | Pillow==2.3.0 74 | collection-json 75 | djangorestframework 76 | pytest 77 | six 78 | 79 | [testenv:py3.2-django1.6] 80 | basepython = python3.2 81 | deps = Django==1.6.3 82 | django-filter==0.7 83 | defusedxml==0.3 84 | Pillow==2.3.0 85 | collection-json 86 | djangorestframework 87 | pytest 88 | six 89 | 90 | [testenv:py2.7-django1.6] 91 | basepython = python2.7 92 | deps = Django==1.6.3 93 | django-filter==0.7 94 | defusedxml==0.3 95 | Pillow==2.3.0 96 | collection-json 97 | djangorestframework 98 | pytest 99 | six 100 | 101 | [testenv:py2.6-django1.6] 102 | basepython = python2.6 103 | deps = Django==1.6.3 104 | django-filter==0.7 105 | defusedxml==0.3 106 | Pillow==2.3.0 107 | collection-json 108 | djangorestframework 109 | pytest 110 | six 111 | 112 | [testenv:py3.4-django1.5] 113 | basepython = python3.4 114 | deps = django==1.5.6 115 | django-filter==0.7 116 | defusedxml==0.3 117 | Pillow==2.3.0 118 | collection-json 119 | djangorestframework 120 | pytest 121 | six 122 | 123 | [testenv:py3.3-django1.5] 124 | basepython = python3.3 125 | deps = django==1.5.6 126 | django-filter==0.7 127 | defusedxml==0.3 128 | Pillow==2.3.0 129 | collection-json 130 | djangorestframework 131 | pytest 132 | six 133 | 134 | [testenv:py3.2-django1.5] 135 | basepython = python3.2 136 | deps = django==1.5.6 137 | django-filter==0.7 138 | defusedxml==0.3 139 | Pillow==2.3.0 140 | collection-json 141 | djangorestframework 142 | pytest 143 | six 144 | 145 | [testenv:py2.7-django1.5] 146 | basepython = python2.7 147 | deps = django==1.5.6 148 | django-filter==0.7 149 | defusedxml==0.3 150 | Pillow==2.3.0 151 | collection-json 152 | djangorestframework 153 | pytest 154 | six 155 | 156 | [testenv:py2.6-django1.5] 157 | basepython = python2.6 158 | deps = django==1.5.6 159 | django-filter==0.7 160 | defusedxml==0.3 161 | Pillow==2.3.0 162 | collection-json 163 | djangorestframework 164 | pytest 165 | six 166 | 167 | [testenv:py2.7-django1.4] 168 | basepython = python2.7 169 | deps = django==1.4.11 170 | django-filter==0.7 171 | defusedxml==0.3 172 | Pillow==2.3.0 173 | collection-json 174 | djangorestframework 175 | pytest 176 | six 177 | 178 | [testenv:py2.6-django1.4] 179 | basepython = python2.6 180 | deps = django==1.4.11 181 | django-filter==0.7 182 | defusedxml==0.3 183 | Pillow==2.3.0 184 | collection-json 185 | djangorestframework 186 | pytest 187 | six 188 | 189 | [testenv:py2.7-django1.3] 190 | basepython = python2.7 191 | deps = django==1.3.5 192 | django-filter==0.5.4 193 | defusedxml==0.3 194 | Pillow==2.3.0 195 | collection-json 196 | djangorestframework 197 | pytest 198 | six 199 | 200 | [testenv:py2.6-django1.3] 201 | basepython = python2.6 202 | deps = django==1.3.5 203 | django-filter==0.5.4 204 | defusedxml==0.3 205 | Pillow==2.3.0 206 | collection-json 207 | djangorestframework 208 | pytest 209 | six 210 | --------------------------------------------------------------------------------