Skip to content

Commit

Permalink
Merge pull request #8 from hannseman/master
Browse files Browse the repository at this point in the history
Add access to APIKey object, repeated url parameters, django <1.5 fixes
  • Loading branch information
andreif committed Jan 31, 2014
2 parents 9173faf + 153dc9d commit 390c42d
Show file tree
Hide file tree
Showing 11 changed files with 84 additions and 95 deletions.
1 change: 1 addition & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ include LICENSE
include README.rst
recursive-include formapi/templates *
recursive-include formapi/static *
recursive-exclude formapi/tests *
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion formapi/__init__.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
29 changes: 25 additions & 4 deletions formapi/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand All @@ -100,14 +102,19 @@ 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')
return key, sign

def sign_ok(self, sign):
pairs = ((field, self.request.REQUEST.get(field))
for field in sorted(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'))
Expand All @@ -117,6 +124,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'
Expand Down Expand Up @@ -191,6 +212,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)
4 changes: 4 additions & 0 deletions formapi/calls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 8 additions & 2 deletions formapi/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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'])
Expand All @@ -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)
Expand All @@ -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):

Expand Down
18 changes: 16 additions & 2 deletions formapi/tests/calls.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,22 @@ def action(self, test):
except ZeroDivisionError:
self.add_error("DIVISION BY ZERO, OH SHIIIIII")

API.register(AuthenticateUserCall, 'user', 'authenticate', version='v1.0.0')
API.register(DivisionCall, 'math', 'divide', version='v1.0.0')

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')
5 changes: 1 addition & 4 deletions formapi/tests/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')))
23 changes: 18 additions & 5 deletions formapi/utils.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,34 @@
import hmac
import urllib2
from hashlib import sha1
from django.utils.encoding import force_unicode
from django.utils.encoding import smart_str, force_unicode


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
"""
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 = 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_unicode('&'.join(param_list))
return hmac.new(str(secret), urllib2.quote(validation_string.encode('utf-8')), sha1).hexdigest()
validation_string = smart_str('&'.join(param_list))
validation_string = urllib2.quote(validation_string)
return hmac.new(str(secret), validation_string, sha1).hexdigest()
1 change: 0 additions & 1 deletion formapi/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,3 @@ def call(request, version, namespace, call_name):
'docstring': form_class.__doc__
}
return render(request, 'formapi/api/call.html', context)

84 changes: 9 additions & 75 deletions setup.py
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -86,12 +20,13 @@ def fullsplit(path, result=None):
author="Hannes Ljungberg",
author_email="[email protected]",
url="https://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',
Expand All @@ -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',
Expand Down

0 comments on commit 390c42d

Please sign in to comment.