├── 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 |
--------------------------------------------------------------------------------