From 5590d25a2feef20cd5c9b29179ca6a21e0baa93b Mon Sep 17 00:00:00 2001 From: Hannes Ljungberg Date: Fri, 28 Jun 2013 15:57:20 -0400 Subject: [PATCH 1/8] Fixes #7 - APICall now has access to the APIKey object --- formapi/api.py | 8 +++++++- formapi/calls.py | 4 ++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/formapi/api.py b/formapi/api.py index b5b4406..a62f33f 100644 --- a/formapi/api.py +++ b/formapi/api.py @@ -100,6 +100,12 @@ def get_form_class(self): except KeyError: raise Http404 + def get_form_kwargs(self): + kwargs = super(API, self).get_form_kwargs() + if self.api_key: + kwargs['api_key'] = self.api_key + return kwargs + def get_access_params(self): key = self.request.REQUEST.get('key') sign = self.request.REQUEST.get('sign') @@ -107,7 +113,7 @@ def get_access_params(self): def sign_ok(self, sign): pairs = ((field, self.request.REQUEST.get(field)) - for field in sorted(self.get_form_class()().fields.keys())) + for field in sorted(self.get_form(self.get_form_class()).fields.keys())) filtered_pairs = itertools.ifilter(lambda x: x[1] is not None, pairs) query_string = '&'.join(('='.join(pair) for pair in filtered_pairs)) query_string = urllib2.quote(query_string.encode('utf-8')) diff --git a/formapi/calls.py b/formapi/calls.py index 9571405..228c017 100644 --- a/formapi/calls.py +++ b/formapi/calls.py @@ -3,6 +3,10 @@ class APICall(forms.Form): + def __init__(self, api_key=None, *args, **kwargs): + super(APICall, self).__init__(*args, **kwargs) + self.api_key = api_key + def add_error(self, error_msg): errors = self.non_field_errors() errors.append(error_msg) From ae439d626738d0b276f5c1ad377bee8a9b958152 Mon Sep 17 00:00:00 2001 From: Hannes Ljungberg Date: Tue, 30 Jul 2013 16:47:36 +0200 Subject: [PATCH 2/8] Fixes #6 - Add support repeated URL parameters --- formapi/api.py | 17 +++++++++++++++-- formapi/tests/__init__.py | 10 ++++++++-- formapi/tests/calls.py | 17 +++++++++++++++++ formapi/utils.py | 23 +++++++++++++++++++---- 4 files changed, 59 insertions(+), 8 deletions(-) diff --git a/formapi/api.py b/formapi/api.py index a62f33f..18411cf 100644 --- a/formapi/api.py +++ b/formapi/api.py @@ -112,8 +112,7 @@ def get_access_params(self): return key, sign def sign_ok(self, sign): - pairs = ((field, self.request.REQUEST.get(field)) - for field in sorted(self.get_form(self.get_form_class()).fields.keys())) + pairs = self.normalized_parameters() filtered_pairs = itertools.ifilter(lambda x: x[1] is not None, pairs) query_string = '&'.join(('='.join(pair) for pair in filtered_pairs)) query_string = urllib2.quote(query_string.encode('utf-8')) @@ -123,6 +122,20 @@ def sign_ok(self, sign): sha1).hexdigest() return constant_time_compare(sign, digest) + def normalized_parameters(self): + """ + Normalize django request to key value pairs sorted by key first and then value + """ + for field in sorted(self.get_form(self.get_form_class()).fields.keys()): + value = self.request.REQUEST.getlist(field) or None + if not value: + continue + if len(value) == 1: + yield field, value[0] + else: + for item in sorted(value): + yield field, item + def render_to_json_response(self, context, **response_kwargs): data = dumps(context) response_kwargs['content_type'] = 'application/json' diff --git a/formapi/tests/__init__.py b/formapi/tests/__init__.py index d55a596..4823ef0 100644 --- a/formapi/tests/__init__.py +++ b/formapi/tests/__init__.py @@ -23,6 +23,7 @@ def setUp(self): self.user.set_password("rosebud") self.user.save() self.authenticate_url = '/api/v1.0.0/user/authenticate/' + self.language_url = '/api/v1.0.0/comp/lang/' def send_request(self, url, data, key=None, secret=None, req_method="POST"): if not key: @@ -42,8 +43,8 @@ def test_api_key(self): def test_valid_auth(self): response = self.send_request(self.authenticate_url, {'username': self.user.username, 'password': 'rosebud'}) - response_data = json.loads(response.content) self.assertEqual(response.status_code, 200) + response_data = json.loads(response.content) self.assertEqual(response_data['errors'], {}) self.assertTrue(response_data['success']) self.assertIsNotNone(response_data['data']) @@ -66,7 +67,7 @@ def test_invalid_sign(self): self.assertEqual(response.status_code, 401) def test_invalid_password(self): - data = {'username': self.user.username, 'password': '1337haxx'} + data = {'username': self.user.username, 'password': '1337hax/x'} response = self.send_request(self.authenticate_url, data) self.assertEqual(response.status_code, 400) response_data = json.loads(response.content) @@ -89,6 +90,11 @@ def test_get_call(self): response = self.send_request(self.authenticate_url, data, req_method='GET') self.assertEqual(response.status_code, 200) + def test_multiple_values(self): + data = {'languages': ['python', 'java']} + response = self.send_request(self.language_url, data, req_method='GET') + self.assertEqual(response.status_code, 200) + class HMACTest(TransactionTestCase): diff --git a/formapi/tests/calls.py b/formapi/tests/calls.py index c845e11..eb06407 100644 --- a/formapi/tests/calls.py +++ b/formapi/tests/calls.py @@ -70,8 +70,25 @@ def action(self, test): except ZeroDivisionError: self.add_error("DIVISION BY ZERO, OH SHIIIIII") + +class ProgrammingLanguages(calls.APICall): + RUBY = 'ruby' + PYTHON = 'python' + JAVA = 'java' + LANGUAGES = ( + (RUBY, 'Freshman'), + (PYTHON, 'Sophomore'), + (JAVA, 'Junior') + ) + languages = forms.MultipleChoiceField(choices=LANGUAGES) + + def action(self, test): + return u'Good for you' + + API.register(AuthenticateUserCall, 'user', 'authenticate', version='v1.0.0') API.register(DivisionCall, 'math', 'divide', version='v1.0.0') +API.register(ProgrammingLanguages, 'comp', 'lang', version='v1.0.0') diff --git a/formapi/utils.py b/formapi/utils.py index abe49a8..25bdcb8 100644 --- a/formapi/utils.py +++ b/formapi/utils.py @@ -1,7 +1,9 @@ import hmac import urllib2 from hashlib import sha1 -from django.utils.encoding import force_unicode +from django.utils.datastructures import MultiValueDict +from django.utils.encoding import force_unicode, force_text, force_bytes +from django.utils.http import urlencode, urlquote def get_sign(secret, querystring=None, **params): @@ -15,7 +17,20 @@ def get_sign(secret, querystring=None, **params): """ if querystring: params = dict(param.split('=') for param in querystring.split('&')) - sorted_params = ((key, params[key]) for key in sorted(params.keys())) + sorted_params = [] + for key, value in sorted(params.items(), key=lambda x: x[0]): + if isinstance(value, basestring): + sorted_params.append((key, value)) + else: + try: + value = list(value) + except TypeError, e: + assert 'is not iterable' in str(e) + value = force_bytes(value) + sorted_params.append((key, value)) + else: + sorted_params.extend((key, item) for item in sorted(value)) param_list = ('='.join((field, force_unicode(value))) for field, value in sorted_params) - validation_string = force_unicode('&'.join(param_list)) - return hmac.new(str(secret), urllib2.quote(validation_string.encode('utf-8')), sha1).hexdigest() + validation_string = force_bytes('&'.join(param_list)) + validation_string = urllib2.quote(validation_string) + return hmac.new(str(secret), validation_string, sha1).hexdigest() From 77d0a8548a61073433fd578eb1431da639735fa3 Mon Sep 17 00:00:00 2001 From: Hannes Ljungberg Date: Tue, 30 Jul 2013 17:02:17 +0200 Subject: [PATCH 3/8] Fix django <1.5 issues and pep8 --- Makefile | 2 +- formapi/tests/calls.py | 3 --- formapi/tests/urls.py | 5 +---- formapi/utils.py | 8 +++----- formapi/views.py | 1 - 5 files changed, 5 insertions(+), 14 deletions(-) diff --git a/Makefile b/Makefile index 7580938..d05124c 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ test: python setup.py test flake8: - flake8 --ignore=E501,E225,E128,W391,W404,W402 --exclude migrations --max-complexity 12 formapi + flake8 --ignore=E501,E128 --exclude migrations --max-complexity 12 formapi install: python setup.py install diff --git a/formapi/tests/calls.py b/formapi/tests/calls.py index eb06407..aa5bd9f 100644 --- a/formapi/tests/calls.py +++ b/formapi/tests/calls.py @@ -89,6 +89,3 @@ def action(self, test): API.register(AuthenticateUserCall, 'user', 'authenticate', version='v1.0.0') API.register(DivisionCall, 'math', 'divide', version='v1.0.0') API.register(ProgrammingLanguages, 'comp', 'lang', version='v1.0.0') - - - diff --git a/formapi/tests/urls.py b/formapi/tests/urls.py index 57b76dc..6427227 100644 --- a/formapi/tests/urls.py +++ b/formapi/tests/urls.py @@ -3,7 +3,4 @@ except ImportError: from django.conf.urls.defaults import patterns, url, include - -urlpatterns = patterns('', - url(r'^api/', include('formapi.urls')), -) +urlpatterns = patterns('', url(r'^api/', include('formapi.urls'))) diff --git a/formapi/utils.py b/formapi/utils.py index 25bdcb8..2ed21ce 100644 --- a/formapi/utils.py +++ b/formapi/utils.py @@ -1,9 +1,7 @@ import hmac import urllib2 from hashlib import sha1 -from django.utils.datastructures import MultiValueDict -from django.utils.encoding import force_unicode, force_text, force_bytes -from django.utils.http import urlencode, urlquote +from django.utils.encoding import smart_str, force_unicode def get_sign(secret, querystring=None, **params): @@ -26,11 +24,11 @@ def get_sign(secret, querystring=None, **params): value = list(value) except TypeError, e: assert 'is not iterable' in str(e) - value = force_bytes(value) + value = smart_str(value) sorted_params.append((key, value)) else: sorted_params.extend((key, item) for item in sorted(value)) param_list = ('='.join((field, force_unicode(value))) for field, value in sorted_params) - validation_string = force_bytes('&'.join(param_list)) + validation_string = smart_str('&'.join(param_list)) validation_string = urllib2.quote(validation_string) return hmac.new(str(secret), validation_string, sha1).hexdigest() diff --git a/formapi/views.py b/formapi/views.py index a02c0fa..40db5b4 100644 --- a/formapi/views.py +++ b/formapi/views.py @@ -17,4 +17,3 @@ def call(request, version, namespace, call_name): 'docstring': form_class.__doc__ } return render(request, 'formapi/api/call.html', context) - From 103eeac34014f39e3076bce1ad2311f55a3f4031 Mon Sep 17 00:00:00 2001 From: Hannes Ljungberg Date: Tue, 30 Jul 2013 17:30:51 +0200 Subject: [PATCH 4/8] Clean up setup.py --- MANIFEST.in | 1 + setup.py | 84 ++++++----------------------------------------------- 2 files changed, 10 insertions(+), 75 deletions(-) diff --git a/MANIFEST.in b/MANIFEST.in index 6449b45..cbbdf70 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,3 +3,4 @@ include LICENSE include README.rst recursive-include formapi/templates * recursive-include formapi/static * +recursive-exclude formapi/tests * diff --git a/setup.py b/setup.py index 1d8760e..dace521 100644 --- a/setup.py +++ b/setup.py @@ -1,80 +1,14 @@ -""" -Based entirely on Django's own ``setup.py``. -""" +#!/usr/bin/env python + import codecs import os -import sys -from distutils.command.install_data import install_data -from distutils.command.install import INSTALL_SCHEMES -try: - from setuptools import setup -except ImportError: - from distutils.core import setup # NOQA - - -class osx_install_data(install_data): - # On MacOS, the platform-specific lib dir is at: - # /System/Library/Framework/Python/.../ - # which is wrong. Python 2.5 supplied with MacOS 10.5 has an Apple-specific - # fix for this in distutils.command.install_data#306. It fixes install_lib - # but not install_data, which is why we roll our own install_data class. - - def finalize_options(self): - # By the time finalize_options is called, install.install_lib is set to - # the fixed directory, so we set the installdir to install_lib. The - # install_data class uses ('install_data', 'install_dir') instead. - self.set_undefined_options('install', ('install_lib', 'install_dir')) - install_data.finalize_options(self) - -if sys.platform == "darwin": - cmdclasses = {'install_data': osx_install_data} -else: - cmdclasses = {'install_data': install_data} - - -def fullsplit(path, result=None): - """ - Split a pathname into components (the opposite of os.path.join) in a - platform-neutral way. - """ - if result is None: - result = [] - head, tail = os.path.split(path) - if head == '': - return [tail] + result - if head == path: - return result - return fullsplit(head, [tail] + result) - -# Tell distutils to put the data_files in platform-specific installation -# locations. See here for an explanation: -# http://groups.google.com/group/comp.lang.python/browse_thread/thread/35ec7b2fed36eaec/2105ee4d9e8042cb -for scheme in INSTALL_SCHEMES.values(): - scheme['data'] = scheme['purelib'] - -# Compile the list of packages available, because distutils doesn't have -# an easy way to do this. -packages, data_files = [], [] -root_dir = os.path.dirname(__file__) -if root_dir != '': - os.chdir(root_dir) -form_dir = 'formapi' - -for dirpath, dirnames, filenames in os.walk(form_dir): - # Ignore dirnames that start with '.' - if os.path.basename(dirpath).startswith("."): - continue - if '__init__.py' in filenames: - packages.append('.'.join(fullsplit(dirpath))) - elif filenames: - data_files.append([dirpath, [os.path.join(dirpath, f) for f in filenames]]) - +from setuptools import setup, find_packages -version = __import__('formapi').__version__ +import formapi setup( name="django-formapi", - version=version, + version=formapi.__version__, description="Django API creation with signed requests utilizing forms for validation.", long_description=codecs.open( @@ -86,12 +20,13 @@ def fullsplit(path, result=None): author="Hannes Ljungberg", author_email="hannes@5monkeys.se", url="http://github.com/5monkeys/django-formapi", - download_url="https://github.com/5monkeys/django-formapi/tarball/%s" % (version,), + download_url="https://github.com/5monkeys/django-formapi/tarball/%s" % (formapi.__version__,), keywords=["django", "formapi", "api", "rpc", "signed", "request", "form", "validation"], platforms=['any'], license='MIT', classifiers=[ "Programming Language :: Python", + 'Programming Language :: Python :: 2', "Programming Language :: Python :: 2.6", "Programming Language :: Python :: 2.7", 'Framework :: Django', @@ -103,9 +38,8 @@ def fullsplit(path, result=None): 'Topic :: Utilities', 'Topic :: Software Development :: Libraries :: Python Modules', ], - cmdclass=cmdclasses, - data_files=data_files, - packages=packages, + packages=find_packages(), + include_package_data=True, install_requires=['django-uuidfield'], tests_require=['Django', 'django-uuidfield', 'pytz'], test_suite='run_tests.main', From 9e0fec1b0a80faa1f89f98409f5264dc31fb0afd Mon Sep 17 00:00:00 2001 From: Hannes Ljungberg Date: Tue, 30 Jul 2013 17:36:49 +0200 Subject: [PATCH 5/8] Bump version --- formapi/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/formapi/__init__.py b/formapi/__init__.py index ee7d871..286c73c 100644 --- a/formapi/__init__.py +++ b/formapi/__init__.py @@ -1,4 +1,4 @@ -VERSION = (0, 0, 7, 'dev') +VERSION = (0, 1, 0, 'dev') # Dynamically calculate the version based on VERSION tuple if len(VERSION) > 2 and VERSION[2] is not None: From c09657f9d714ca24d245b6771aa1ccbdacdf2984 Mon Sep 17 00:00:00 2001 From: Hannes Ljungberg Date: Tue, 30 Jul 2013 23:30:53 +0200 Subject: [PATCH 6/8] Log 401 requests with level WARNING --- formapi/api.py | 2 +- formapi/utils.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/formapi/api.py b/formapi/api.py index 18411cf..0923086 100644 --- a/formapi/api.py +++ b/formapi/api.py @@ -210,6 +210,6 @@ def dispatch(self, request, *args, **kwargs): return super(API, self).dispatch(request, *args, **kwargs) # Access denied - self.log.info('Access Denied %s', self.request.REQUEST) + self.log.warning('Access Denied %s', self.request.REQUEST) return HttpResponse(status=401) diff --git a/formapi/utils.py b/formapi/utils.py index 2ed21ce..af648b5 100644 --- a/formapi/utils.py +++ b/formapi/utils.py @@ -9,7 +9,7 @@ def get_sign(secret, querystring=None, **params): Return sign for querystring. Logic: - - Sort querystring by parameter keys + - Sort querystring by parameter keys and by value if two or more parameter keys share the same name - URL encode sorted querystring - Generate a hex digested hmac/sha1 hash using given secret """ From aa603e157cb4cc455813d652137e45e7afb213de Mon Sep 17 00:00:00 2001 From: Hannes Ljungberg Date: Mon, 19 Aug 2013 22:55:47 +0200 Subject: [PATCH 7/8] Handle timedelta json encode --- formapi/api.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/formapi/api.py b/formapi/api.py index 0923086..c5d3148 100644 --- a/formapi/api.py +++ b/formapi/api.py @@ -75,6 +75,8 @@ def default_date(self, obj): if obj.microsecond: r = r[:12] return r + elif isinstance(obj, datetime.timedelta): + return obj.seconds dumps = curry(dumps, cls=DjangoJSONEncoder) From 153dc9d03f31b1c83c075b474cc8856aabfbadba Mon Sep 17 00:00:00 2001 From: Hannes Ljungberg Date: Wed, 9 Oct 2013 20:35:38 +0200 Subject: [PATCH 8/8] Fix bug where json encoded timedeltas returning 0 seconds failed --- formapi/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/formapi/api.py b/formapi/api.py index c5d3148..846abb9 100644 --- a/formapi/api.py +++ b/formapi/api.py @@ -43,7 +43,7 @@ class DjangoJSONEncoder(JSONEncoder): def default(self, obj): date_obj = self.default_date(obj) - if date_obj: + if date_obj is not None: return date_obj elif isinstance(obj, decimal.Decimal): return str(obj)