├── django_api ├── __init__.py ├── models.py ├── json_helpers.py ├── decorators.py └── tests.py ├── MANIFEST.in ├── AUTHORS ├── .gitignore ├── setup.py ├── LICENSE └── README.rst /django_api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_api/models.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Primary authors: 2 | 3 | * Bipin Suresh 4 | * Jerry Charumilind 5 | 6 | Contributors: 7 | * Junjie Liang 8 | * Jean Feng 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | bin/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # Installer logs 26 | pip-log.txt 27 | pip-delete-this-directory.txt 28 | 29 | # Unit test / coverage reports 30 | htmlcov/ 31 | .tox/ 32 | .coverage 33 | .cache 34 | nosetests.xml 35 | coverage.xml 36 | 37 | # Translations 38 | *.mo 39 | 40 | # Mr Developer 41 | .mr.developer.cfg 42 | .project 43 | .pydevproject 44 | 45 | # Rope 46 | .ropeproject 47 | 48 | # Django stuff: 49 | *.log 50 | *.pot 51 | 52 | # Sphinx documentation 53 | docs/_build/ 54 | 55 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup 3 | 4 | 5 | with open(os.path.join(os.path.dirname(__file__), 'README.rst')) as readme: 6 | README = readme.read() 7 | 8 | # allow setup.py to be run from any path 9 | os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) 10 | 11 | setup( 12 | name='django-api', 13 | version='0.1.3', 14 | packages=['django_api'], 15 | url='https://github.com/bipsandbytes/django-api', 16 | include_package_data=True, 17 | license='BSD License', 18 | description='Specify and validate your Django APIs', 19 | long_description=README, 20 | author='Bipin Suresh', 21 | author_email='bipins@alumni.stanford.edu', 22 | classifiers=[ 23 | 'Environment :: Web Environment', 24 | 'Framework :: Django', 25 | 'Intended Audience :: Developers', 26 | 'License :: OSI Approved :: BSD License', 27 | 'Operating System :: OS Independent', 28 | 'Programming Language :: Python', 29 | 'Topic :: Internet :: WWW/HTTP', 30 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 31 | ], 32 | ) 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014, bipsandbytes 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of the {organization} nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /django_api/json_helpers.py: -------------------------------------------------------------------------------- 1 | from django.core import serializers 2 | from django.db import models 3 | from django.forms.models import model_to_dict 4 | from django.http import HttpResponse 5 | 6 | 7 | class JsonResponseEncoder(serializers.json.DjangoJSONEncoder): 8 | """ 9 | JSON encoder that converts Django types to JSON. 10 | """ 11 | def default(self, obj): 12 | """ 13 | Convert QuerySet objects to their list counter-parts 14 | """ 15 | if isinstance(obj, models.Model): 16 | return self.encode(model_to_dict(obj)) 17 | elif isinstance(obj, models.query.QuerySet): 18 | return serializers.serialize('json', obj) 19 | else: 20 | return super(JsonResponseEncoder, self).default(obj) 21 | 22 | 23 | class JsonResponse(HttpResponse): 24 | def __init__(self, data={}): 25 | super(JsonResponse, self).__init__(content_type='application/json') 26 | self.set_content(data) 27 | 28 | def set_content(self, data): 29 | json_encoder = JsonResponseEncoder(separators=(',', ':')) 30 | content = json_encoder.encode(data) 31 | self.content = content 32 | 33 | 34 | class JsonResponseCreated(JsonResponse): 35 | status_code = 201 36 | 37 | 38 | class JsonResponseAccepted(JsonResponse): 39 | status_code = 202 40 | 41 | 42 | class JsonResponseBadRequest(JsonResponse): 43 | status_code = 400 44 | 45 | 46 | class JsonResponseWithStatus(JsonResponse): 47 | def __init__(self, error_message='', error_type=None, field_errors=None): 48 | data = { 49 | 'error_type': error_type or self.status_code, 50 | 'error_message': error_message 51 | } 52 | if field_errors: 53 | data['field_errors'] = field_errors 54 | super(JsonResponseWithStatus, self).__init__(data) 55 | 56 | 57 | class JsonResponseSeeOther(JsonResponseWithStatus): 58 | status_code = 302 59 | 60 | 61 | class JsonResponseForbidden(JsonResponseWithStatus): 62 | status_code = 403 63 | 64 | 65 | class JsonResponseConflict(JsonResponseWithStatus): 66 | status_code = 409 67 | 68 | 69 | class JsonResponseError(JsonResponseWithStatus): 70 | status_code = 500 71 | 72 | 73 | class JsonResponseUnauthorized(JsonResponseWithStatus): 74 | status_code = 401 75 | 76 | 77 | class JsonResponseNotFound(JsonResponseWithStatus): 78 | status_code = 404 79 | 80 | 81 | class JsonResponseNotSupported(JsonResponseWithStatus): 82 | status_code = 400 83 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================= 2 | Django API 3 | ================= 4 | 5 | ``django_api`` lets you specify and validate your Django_ APIs in a single block of code. 6 | 7 | It does this with an ``@api`` decorator that wraps Django views. This keeps the API documentation consistent, localized and declarative. 8 | 9 | :: 10 | 11 | from django import forms 12 | from django_api.decorators import api 13 | from django_api.json_helpers import JsonResponse 14 | from django_api.json_helpers import JsonResponseForbidden 15 | 16 | 17 | @api({ 18 | 'accepts': { 19 | 'x': forms.IntegerField(min_value=0), 20 | 'y': forms.IntegerField(max_value=10), 21 | }, 22 | 'returns': { 23 | 200: 'Addition successful', 24 | } 25 | }) 26 | def add(request, *args, **kwargs): 27 | # `x` and `y` have been validated, and are integers 28 | # so we can safely perform arithmetic operations on them 29 | return JsonResponse({ 30 | 'sum': request.GET['x'] + request.GET['y'] 31 | }) 32 | 33 | 34 | .. _Django: https://www.djangoproject.com/ 35 | 36 | Based on the above example, the following API responses are automatically available (assuming you wire up the ``/add`` route to the view above: 37 | 38 | :: 39 | 40 | GET /add 41 | 42 | "failed to validate: {'y': [u'This field is required.'], 'x': [u'This field is required.']}" 43 | 44 | 45 | GET /add?x=10 46 | 47 | "failed to validate: {'y': [u'This field is required.']}" 48 | 49 | 50 | GET /add?x=10&y=100 51 | 52 | "failed to validate: {'y': [u'Ensure this value is less than or equal to 10.']}" 53 | 54 | 55 | GET /add?x=10&y=10 56 | 57 | {sum: 20} 58 | 59 | 60 | ------------ 61 | Dependencies 62 | ------------ 63 | 64 | None 65 | 66 | ------------ 67 | Installation 68 | ------------ 69 | 70 | To use ``django_api`` in your Django project it needs to be accessible by your 71 | Python installation:: 72 | 73 | $ pip install django-api 74 | 75 | (or simply place the ``django_api`` directory in your $PYTHON_PATH) 76 | 77 | ------------ 78 | Django Setup 79 | ------------ 80 | 81 | Add ``django_api`` to ``INSTALLED_APPS`` in your project's ``settings.py``. 82 | 83 | Example:: 84 | 85 | INSTALLED_APPS = ( 86 | 'django.contrib.auth', 87 | 'django.contrib.contenttypes', 88 | 'django.contrib.sessions', 89 | 'django.contrib.sites', 90 | 'django.contrib.admin', 91 | 'django_api', 92 | ) 93 | 94 | 95 | ----- 96 | Usage 97 | ----- 98 | 99 | Specify your API by using the ``@api`` decorator. The ``@api`` decorator takes a dictionary with two keys: ``accepts`` and ``returns``. 100 | 101 | :: 102 | 103 | from django_api.decorators import api 104 | @api({ 105 | 'accepts': { 106 | }, 107 | 'returns': { 108 | } 109 | }) 110 | def add(request, *args, **kwargs): 111 | 112 | 113 | accepts 114 | ------- 115 | 116 | Describe the query parameters your API accepts by listing them out in the ``accepts`` dictionary. Each entry in the ``accepts`` section 117 | maps a name to a `Django form field 118 | `_ type. 119 | Received query parameters are automatically converted to the specified type. If the parameter does not conform to the specification 120 | the query fails to validate (see below). 121 | Once validated, the variables will be placed in the ``request`` dictionary for use within the view. 122 | 123 | 124 | :: 125 | 126 | 'accepts': { 127 | 'x': forms.IntegerField(min_value=0), 128 | 'y': forms.IntegerField(max_value=10, required=False), 129 | 'u': User(), 130 | } 131 | 132 | Since each parameter is specified using a Django form field, any argument that its class constructor takes can be used. Examples include 133 | 134 | * ``required`` 135 | * ``initial`` 136 | * ``max_length`` for ``CharField`` 137 | * ``min_value`` for ``IntegerField`` 138 | 139 | For a full reference, please `see here `_. 140 | 141 | returns 142 | ------- 143 | 144 | By default, the ``@api`` decorator checks that the returned response is of JSON type. 145 | 146 | Specify the valid returned HTTP codes by listing them out in the ``returns`` dictionary. 147 | Each entry in the dictionary maps a HTTP response code to a helpful message, explaining the outcome 148 | of the action. The helpful message is for documentation purposes only. 149 | If the response does not conform to the specification, the query will fail to validate (see below). 150 | 151 | :: 152 | 153 | 'returns': { 154 | 200: 'Addition successful', 155 | 403: 'User does not have permission', 156 | 404: 'Resource not found', 157 | 404: 'User not found', 158 | } 159 | 160 | 161 | Validation 162 | ---------- 163 | If validation fails, a ``HTTP 400 - Bad request`` is returned to the client. For safety, ``django_api`` will perform validation only if ``settings.DEBUG = True``. 164 | This ensures that production code always remains unaffected. 165 | 166 | 167 | Testing 168 | ---------- 169 | Run the tests with the folllowing command 170 | 171 | :: 172 | 173 | python manage.py test django_api 174 | 175 | 176 | -------------- 177 | Advanced usage 178 | -------------- 179 | 180 | Django Models 181 | -------------- 182 | 183 | ``@accepts`` can be used to also accept your Django models through the object's ``id``. For a Model ``Model``, Django expects the query parameter to be name ``model-id``. 184 | 185 | :: 186 | 187 | 'accepts': { 188 | 'x': forms.IntegerField(min_value=0), 189 | 'y': forms.IntegerField(max_value=10, required=False), 190 | 'u': User(), 191 | } 192 | 193 | You can also simply choose to validate either only the parameters the 194 | API accepts, or the return values of the API. 195 | 196 | Example:: 197 | 198 | 199 | from django import forms 200 | from django_api.decorators import api_accepts 201 | from django_api.json_helpers import JsonResponse 202 | from django_api.json_helpers import JsonResponseForbidden 203 | 204 | 205 | @api_accepts({ 206 | 'x': forms.IntegerField(min_value=0), 207 | 'y': forms.IntegerField(min_value=0), 208 | }) 209 | def add(request, *args, **kwargs): 210 | return JsonResponse({ 211 | 'sum': request.GET['x'] + request.GET['y'] 212 | }) 213 | 214 | 215 | 216 | 217 | from django import forms 218 | from django_api.decorators import api_returns 219 | from django_api.json_helpers import JsonResponse 220 | from django_api.json_helpers import JsonResponseForbidden 221 | 222 | 223 | @api_returns({ 224 | 200: 'Operation successful', 225 | 403: 'User does not have permission', 226 | 404: 'Resource not found', 227 | 404: 'User not found', 228 | }) 229 | def add(request, *args, **kwargs): 230 | return JsonResponse({ 231 | 'sum': request.GET['x'] + request.GET['y'] 232 | }) 233 | -------------------------------------------------------------------------------- /django_api/decorators.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from functools import wraps 4 | from django import forms 5 | from django.conf import settings 6 | from django.db import models 7 | from django_api.json_helpers import JsonResponse 8 | from django_api.json_helpers import JsonResponseBadRequest 9 | from django_api.json_helpers import JsonResponseNotFound 10 | 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class ValidatedRequest(object): 16 | """ 17 | A wrapper for Request objects that behaves like a Request in every way 18 | except that GET/POST data is replaced with a cleaned version (per Django 19 | forms). 20 | 21 | See the @api decorator for more details. 22 | """ 23 | 24 | def __init__(self, orig_request, form): 25 | self._orig_request = orig_request 26 | self._form = form 27 | 28 | def __getattr__(self, name): 29 | if name in ['GET', 'POST']: 30 | return self._form.cleaned_data 31 | else: 32 | return object.__getattribute__(self._orig_request, name) 33 | 34 | 35 | def api_accepts(fields): 36 | """ 37 | Define the accept schema of an API (GET or POST). 38 | 39 | 'fields' is a dict of Django form fields keyed by field name that specifies 40 | the form-urlencoded fields that the API accepts*. 41 | 42 | The view function is then called with GET/POST data that has been cleaned 43 | by the Django form. 44 | 45 | In debug and test modes, failure to validate the fields will result in a 46 | 400 Bad Request response. 47 | In production mode, failure to validate will just log a 48 | warning, unless overwritten by a 'strict' setting. 49 | 50 | For example: 51 | 52 | @api_accepts({ 53 | 'x': forms.IntegerField(min_value=0), 54 | 'y': forms.IntegerField(min_value=0), 55 | }) 56 | def add(request, *args, **kwargs): 57 | x = request.POST['x'] 58 | y = request.POST['y'] 59 | 60 | # x and y are integers already. 61 | return HttpResponse('%d' % (x + y)) 62 | 63 | 64 | *: 'fields' can also include Django models as {'key': Model()}. If present, 65 | api_accepts will look for the field keyed by '-id' 66 | and pick the object that has that primary key. For example, if the entry is 67 | {'course': Course()}, it will search for the key course_id='course-id' in 68 | the request object, and find the object Course.objects.get(pk=course_id) 69 | """ 70 | def decorator(func): 71 | @wraps(func) 72 | def wrapped_func(request, *args, **kwargs): 73 | if request.method not in ['GET', 'POST']: 74 | return func(request, *args, **kwargs) 75 | 76 | # The fields dict passed into the type() function is modified, so 77 | # send in a copy instead. 78 | form_class = type('ApiForm', (forms.Form,), fields.copy()) 79 | form = form_class(getattr(request, request.method)) 80 | 81 | if not form.is_valid(): 82 | if settings.DEBUG: 83 | return JsonResponseBadRequest( 84 | 'failed to validate: %s' % dict(form.errors) 85 | ) 86 | else: 87 | logger.warn( 88 | 'input to \'%s\' failed to validate: %s', 89 | request.path, 90 | dict(form.errors) 91 | ) 92 | return func(request, *args, **kwargs) 93 | 94 | # Clean any models.Model fields, by looking up object based on 95 | # primary key in request. 96 | for (field_name, field_instance) in fields.items(): 97 | if isinstance(field_instance, models.Model): 98 | field_type = type(field_instance) 99 | # TODO: irregular, should we remove? 100 | field_id = '%s-id' % field_name 101 | if field_id not in request.REQUEST: 102 | return JsonResponseBadRequest( 103 | 'field %s not present' % field_name 104 | ) 105 | field_pk = int(request.REQUEST[field_id]) 106 | try: 107 | field_value = field_type.objects.get(pk=field_pk) 108 | except field_type.DoesNotExist: 109 | return JsonResponseNotFound( 110 | '%s with pk=%d does not exist' % ( 111 | field_type, field_pk 112 | ) 113 | ) 114 | form.cleaned_data[field_name] = field_value 115 | 116 | validated_request = ValidatedRequest(request, form) 117 | return func(validated_request, *args, **kwargs) 118 | return wrapped_func 119 | return decorator 120 | 121 | 122 | def api_returns(return_values): 123 | """ 124 | Define the return schema of an API. 125 | 126 | 'return_values' is a dictionary mapping 127 | HTTP return code => documentation 128 | In addition to validating that the status code of the response belongs to 129 | one of the accepted status codes, it also validates that the returned 130 | object is JSON (derived from JsonResponse) 131 | 132 | In debug and test modes, failure to validate the fields will result in a 133 | 400 Bad Request response. 134 | In production mode, failure to validate will just log a 135 | warning, unless overwritten by a 'strict' setting. 136 | 137 | For example: 138 | 139 | @api_returns({ 140 | 200: 'Operation successful', 141 | 403: 'User does not have persion', 142 | 404: 'Resource not found', 143 | 404: 'User not found', 144 | }) 145 | def add(request, *args, **kwargs): 146 | if not request.user.is_superuser: 147 | return JsonResponseForbidden() # 403 148 | 149 | return HttpResponse() # 200 150 | """ 151 | def decorator(func): 152 | @wraps(func) 153 | def wrapped_func(request, *args, **kwargs): 154 | return_value = func(request, *args, **kwargs) 155 | 156 | if not isinstance(return_value, JsonResponse): 157 | if settings.DEBUG: 158 | return JsonResponseBadRequest('API did not return JSON') 159 | else: 160 | logger.warn('API did not return JSON') 161 | 162 | accepted_return_codes = return_values.keys() 163 | # Never block 500s - these should be handled by other 164 | # reporting mechanisms 165 | accepted_return_codes.append(500) 166 | 167 | if return_value.status_code not in accepted_return_codes: 168 | if settings.DEBUG: 169 | return JsonResponseBadRequest( 170 | 'API returned %d instead of acceptable values %s' % 171 | (return_value.status_code, accepted_return_codes) 172 | ) 173 | else: 174 | logger.warn( 175 | 'API returned %d instead of acceptable values %s', 176 | return_value.status_code, 177 | accepted_return_codes, 178 | ) 179 | 180 | return return_value 181 | return wrapped_func 182 | return decorator 183 | 184 | 185 | def api(accept_return_dict): 186 | """ 187 | Wrapper that calls @api_accepts and @api_returns in sequence. 188 | For example: 189 | 190 | @api({ 191 | 'accepts': { 192 | 'x': forms.IntegerField(min_value=0), 193 | 'y': forms.IntegerField(min_value=0), 194 | }, 195 | 'returns': [ 196 | 200: 'Operation successful', 197 | 403: 'User does not have persion', 198 | 404: 'Resource not found', 199 | 404: 'User not found', 200 | ] 201 | }) 202 | def add(request, *args, **kwargs): 203 | if not request.GET['x'] == 10: 204 | return JsonResponseForbidden() # 403 205 | 206 | return HttpResponse() # 200 207 | """ 208 | def decorator(func): 209 | @wraps(func) 210 | def wrapped_func(request, *args, **kwargs): 211 | @api_accepts(accept_return_dict['accepts']) 212 | @api_returns(accept_return_dict['returns']) 213 | def apid_fnc(request, *args, **kwargs): 214 | return func(request, *args, **kwargs) 215 | 216 | return apid_fnc(request, *args, **kwargs) 217 | return wrapped_func 218 | return decorator 219 | 220 | 221 | def validate_json_request(required_fields): 222 | """ 223 | Return a decorator that ensures that the request passed to the view 224 | function/method has a valid JSON request body with the given required 225 | fields. The dict parsed from the JSON is then passed as the second 226 | argument to the decorated function/method. For example: 227 | 228 | @json_request({'name', 'date'}) 229 | def view_func(request, request_dict): 230 | ... 231 | """ 232 | def decorator(func): 233 | @wraps(func) 234 | def wrapped_func(request, *args, **kwargs): 235 | try: 236 | request_dict = json.loads(request.raw_post_data) 237 | except ValueError as e: 238 | return JsonResponseBadRequest('invalid POST JSON: %s' % e) 239 | 240 | for k in required_fields: 241 | if k not in request_dict: 242 | return JsonResponseBadRequest( 243 | 'POST JSON must contain property \'%s\'' % k) 244 | 245 | return func(request, request_dict, *args, **kwargs) 246 | return wrapped_func 247 | return decorator 248 | -------------------------------------------------------------------------------- /django_api/tests.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | import logging 4 | from django import forms 5 | from django.test import TestCase 6 | from django.test.client import RequestFactory 7 | from django.test.utils import override_settings 8 | from django_api.decorators import api 9 | from django_api.decorators import api_accepts 10 | from django_api.decorators import api_returns 11 | from django_api.json_helpers import JsonResponse 12 | from django_api.json_helpers import JsonResponseForbidden 13 | from django_api.json_helpers import JsonResponseAccepted 14 | from django_api.json_helpers import JsonResponseWithStatus 15 | from django.contrib.auth.models import User 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | class SimpleTest(TestCase): 21 | 22 | @override_settings(DEBUG=True) 23 | def test_api_accepts_decorator_debug(self): 24 | """ 25 | Test the @api_accepts decorator (with DEBUG=True). 26 | """ 27 | rf = RequestFactory() 28 | 29 | # Test that POSTs are validated. 30 | @api_accepts({ 31 | 'im_required': forms.IntegerField(), 32 | }) 33 | def my_post_view(request, *args, **kwargs): 34 | self.assertIsInstance(request.POST['im_required'], int) 35 | return JsonResponseWithStatus('post called') 36 | 37 | request = rf.post('/post_success', data={'im_required': '10'}) 38 | response = my_post_view(request) 39 | self.assertEquals(response.status_code, 200) 40 | response_json = json.loads(response.content) 41 | self.assertEquals(response_json['error_message'], 'post called') 42 | 43 | # Test that GETs are validated. 44 | @api_accepts({ 45 | 'im_required': forms.IntegerField(), 46 | }) 47 | def my_get_view(request, *args, **kwargs): 48 | self.assertIsInstance(request.GET['im_required'], int) 49 | return JsonResponseWithStatus('get called') 50 | 51 | request = rf.get('/get_success', data={'im_required': '10'}) 52 | response = my_get_view(request) 53 | self.assertEquals(response.status_code, 200) 54 | response_json = json.loads(response.content) 55 | self.assertEquals(response_json['error_message'], 'get called') 56 | 57 | # Test that POSTs are validated. 58 | @api_accepts({ 59 | 'im_required': forms.IntegerField(), 60 | }) 61 | def my_failed_post_view(request, *args, **kwargs): 62 | return JsonResponseWithStatus('not called on failure') 63 | 64 | # Test that failed validation results in a 400 Bad Request when DEBUG 65 | # == True. 66 | request = rf.post('/post_failure', data={'hello': 'world'}) 67 | response = my_failed_post_view(request) 68 | self.assertEquals(response.status_code, 400) 69 | 70 | # Test that Django models can be accepted by @api 71 | user = User.objects.create() 72 | @api_accepts({ 73 | 'im_required': forms.IntegerField(), 74 | 'user': User(), 75 | }) 76 | def model_view(request, *args, **kwargs): 77 | self.assertTrue(request.GET['user'].id == user.id) 78 | return JsonResponse() 79 | 80 | request = rf.get('/model_failure', data={'im_required': 10, 'user-id': 99999}) 81 | response = model_view(request) 82 | self.assertEquals(response.status_code, 404) 83 | 84 | request = rf.get('/model_success', data={'im_required': 10, 'user-id': user.id}) 85 | response = model_view(request) 86 | self.assertEquals(response.status_code, 200) 87 | 88 | # Test that datetime are handled by @api_accepts 89 | @api_accepts({ 90 | 'date_param': forms.DateField(), 91 | 'datetime_param': forms.DateTimeField(input_formats=['%Y-%m-%dT%H:%M:%S']), 92 | 'time_param': forms.TimeField(), 93 | }) 94 | def datetime_view(request, *args, **kwargs): 95 | self.assertTrue(request.GET['date_param'], datetime.date(2010, 2, 17)) 96 | self.assertTrue(request.GET['datetime_param'], datetime.datetime(2006, 11, 21, 16, 30)) 97 | self.assertTrue(request.GET['time_param'], datetime.time(16, 30)) 98 | return JsonResponse() 99 | request = rf.get('/datetime_success', data={ 100 | 'date_param': '2010-02-17', 101 | 'datetime_param': '2006-11-21T16:30:00', 102 | 'time_param': '16:30:00', 103 | }) 104 | response = datetime_view(request) 105 | self.assertEquals(response.status_code, 200) 106 | 107 | @override_settings(DEBUG=False) 108 | def test_api_accepts_decorator(self): 109 | """ 110 | Test that failures to validate with the @api_accepts decorator do not result in 111 | 400 Bad Requests in production. 112 | 113 | This is a temporary measure to avoid breaking production as we apply 114 | this decorator to existing APIs. 115 | """ 116 | original_log_level = logger.level 117 | logger.setLevel(1000) 118 | 119 | # Test that POSTs are validated. 120 | @api_accepts({ 121 | 'im_required': forms.IntegerField(), 122 | }) 123 | def my_failed_post_view(request, *args, **kwargs): 124 | return JsonResponseWithStatus('still called on failure') 125 | 126 | rf = RequestFactory() 127 | request = rf.post('/post_failure', data={'hello': 'world'}) 128 | response = my_failed_post_view(request) 129 | self.assertEquals(response.status_code, 200) 130 | response_json = json.loads(response.content) 131 | self.assertEquals(response_json['error_message'], 'still called on failure') 132 | logger.setLevel(original_log_level) 133 | 134 | @override_settings(DEBUG=True) 135 | def test_api_returns_decorator_debug(self): 136 | """ 137 | Test that the @api_returns decorator (in debug mode) 138 | """ 139 | @api_returns({ 140 | 200: 'OK', 141 | 403: 'Permission denied', 142 | }) 143 | def simple_view(request): 144 | if 'ok' in request.GET: 145 | return JsonResponse() 146 | elif 'noperm' in request.GET: 147 | return JsonResponseForbidden() 148 | else: 149 | return JsonResponseAccepted() # Not declared in @api_returns 150 | 151 | rf = RequestFactory() 152 | request = rf.get('/simple_view', data={'ok': '1'}) 153 | response = simple_view(request) 154 | self.assertEquals(response.status_code, 200) 155 | 156 | request = rf.get('/simple_view', data={'noperm': '1'}) 157 | response = simple_view(request) 158 | self.assertEquals(response.status_code, 403) 159 | 160 | request = rf.get('/simple_view', data={'bad': '1'}) 161 | response = simple_view(request) 162 | self.assertEquals(response.status_code, 400) 163 | 164 | @api_returns({ 165 | 200: 'OK', 166 | }) 167 | def datetime_view(request): 168 | # send complex objects 169 | user = User.objects.create() 170 | users = User.objects.all() 171 | return JsonResponse({ 172 | 'date': datetime.date(2010, 2, 17), 173 | 'datetime': datetime.datetime(2006, 11, 21, 16, 30), 174 | 'time': datetime.time(16, 30), 175 | 'user': user, 176 | 'users': users, 177 | }) 178 | 179 | request = rf.get('/datetime_view') 180 | response = datetime_view(request) 181 | self.assertEquals(response.status_code, 200) 182 | 183 | @override_settings(DEBUG=False) 184 | def test_api_returns_decorator(self): 185 | """ 186 | Test that the @api_returns decorator does not result in a 400 187 | in production. 188 | 189 | This is a temporary measure to avoid breaking production as we apply 190 | this decorator to existing APIs. 191 | """ 192 | original_log_level = logger.level 193 | logger.setLevel(1000) 194 | 195 | @api_returns({ 196 | 200: 'OK', 197 | 403: 'Permission denied', 198 | }) 199 | def simple_view(request): 200 | if 'ok' in request.GET: 201 | return JsonResponse() 202 | elif 'noperm' in request.GET: 203 | return JsonResponseForbidden() 204 | else: 205 | return JsonResponseAccepted() # Not declared in @api_returns 206 | 207 | rf = RequestFactory() 208 | request = rf.get('/simple_view', data={'bad': '1'}) 209 | response = simple_view(request) 210 | self.assertEquals(response.status_code, 202) 211 | logger.setLevel(original_log_level) 212 | 213 | @override_settings(DEBUG=True) 214 | def test_api_decorator_debug(self): 215 | """ 216 | Test that the @api decorator (in debug mode) 217 | """ 218 | @api({ 219 | 'accepts': { 220 | 'im_required': forms.IntegerField(), 221 | 'ok': forms.IntegerField(required=False), 222 | }, 223 | 'returns': { 224 | 200: 'OK', 225 | 403: 'Permission denied', 226 | }, 227 | }) 228 | def simple_view(request): 229 | if request.GET['ok']: 230 | return JsonResponse() 231 | else: 232 | return JsonResponseAccepted() # Not declared in @api_returns 233 | 234 | rf = RequestFactory() 235 | request = rf.get('/simple_view', data={'im_required': '1', 'ok': '1'}) 236 | response = simple_view(request) 237 | self.assertEquals(response.status_code, 200) 238 | 239 | # fail accepts 240 | request = rf.get('/simple_view', data={'ok': '1'}) 241 | response = simple_view(request) 242 | self.assertEquals(response.status_code, 400) 243 | 244 | # fail returns 245 | request = rf.get('/simple_view', data={'im_required': '1'}) 246 | response = simple_view(request) 247 | self.assertEquals(response.status_code, 400) 248 | --------------------------------------------------------------------------------