From 0e87ae0739ed3e72301b7b718098f97a7f06a5d8 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Marek=20St=C4=99pniowski?= Date: Thu, 28 Jan 2010 23:22:09 +0100 Subject: [PATCH] =?utf8?q?Dodanie=20omy=C5=82kowo=20pomini=C4=99tego=20API?= =?utf8?q?.?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit --- apps/api/__init__.py | 0 apps/api/handlers.py | 31 + apps/api/urls.py | 17 + apps/piston/__init__.py | 0 apps/piston/authentication.py | 317 +++++++++ apps/piston/decorator.py | 186 +++++ apps/piston/doc.py | 195 ++++++ apps/piston/emitters.py | 441 ++++++++++++ apps/piston/fixtures/models.json | 46 ++ apps/piston/fixtures/oauth.json | 27 + apps/piston/forms.py | 62 ++ apps/piston/handler.py | 156 +++++ apps/piston/managers.py | 68 ++ apps/piston/middleware.py | 20 + apps/piston/models.py | 150 ++++ apps/piston/oauth.py | 650 ++++++++++++++++++ apps/piston/resource.py | 260 +++++++ apps/piston/signals.py | 14 + apps/piston/store.py | 75 ++ apps/piston/templates/documentation.html | 55 ++ .../templates/piston/authorize_token.html | 15 + apps/piston/test.py | 62 ++ apps/piston/tests.py | 44 ++ apps/piston/utils.py | 313 +++++++++ 24 files changed, 3204 insertions(+) create mode 100644 apps/api/__init__.py create mode 100644 apps/api/handlers.py create mode 100644 apps/api/urls.py create mode 100644 apps/piston/__init__.py create mode 100644 apps/piston/authentication.py create mode 100755 apps/piston/decorator.py create mode 100644 apps/piston/doc.py create mode 100644 apps/piston/emitters.py create mode 100644 apps/piston/fixtures/models.json create mode 100644 apps/piston/fixtures/oauth.json create mode 100644 apps/piston/forms.py create mode 100644 apps/piston/handler.py create mode 100644 apps/piston/managers.py create mode 100644 apps/piston/middleware.py create mode 100644 apps/piston/models.py create mode 100644 apps/piston/oauth.py create mode 100644 apps/piston/resource.py create mode 100644 apps/piston/signals.py create mode 100644 apps/piston/store.py create mode 100644 apps/piston/templates/documentation.html create mode 100644 apps/piston/templates/piston/authorize_token.html create mode 100644 apps/piston/test.py create mode 100644 apps/piston/tests.py create mode 100644 apps/piston/utils.py diff --git a/apps/api/__init__.py b/apps/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/api/handlers.py b/apps/api/handlers.py new file mode 100644 index 000000000..03115d894 --- /dev/null +++ b/apps/api/handlers.py @@ -0,0 +1,31 @@ +from django.shortcuts import get_object_or_404 +from django.contrib.auth.decorators import login_required, user_passes_test +from piston.handler import BaseHandler +from piston.utils import rc, validate +from catalogue.models import Book +from catalogue.forms import BookImportForm + + +staff_required = user_passes_test(lambda user: user.is_staff) + + +class BookHandler(BaseHandler): + model = Book + fields = ('slug', 'title') + + @staff_required + def read(self, request, slug=None): + if slug: + return get_object_or_404(Book, slug=slug) + else: + return Book.objects.all() + + @staff_required + def create(self, request): + form = BookImportForm(request.POST, request.FILES) + if form.is_valid(): + form.save() + return rc.CREATED + else: + return rc.BAD_REQUEST + diff --git a/apps/api/urls.py b/apps/api/urls.py new file mode 100644 index 000000000..8b1b9b0cb --- /dev/null +++ b/apps/api/urls.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +from django.conf.urls.defaults import * +from piston.resource import Resource +from piston.authentication import HttpBasicAuthentication + +from api.handlers import BookHandler + + +auth = HttpBasicAuthentication(realm='My sample API') +book_resource = Resource(handler=BookHandler, authentication=auth) + + +urlpatterns = patterns('', + url(r'^books/(?P[^/]+)\.(?Pxml|json|yaml)$', book_resource), + url(r'^books\.(?Pxml|json|yaml)$', book_resource), +) + diff --git a/apps/piston/__init__.py b/apps/piston/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/piston/authentication.py b/apps/piston/authentication.py new file mode 100644 index 000000000..7d09707cb --- /dev/null +++ b/apps/piston/authentication.py @@ -0,0 +1,317 @@ +import binascii + +import oauth +from django.http import HttpResponse, HttpResponseRedirect +from django.contrib.auth.models import User, AnonymousUser +from django.contrib.auth.decorators import login_required +from django.template import loader +from django.contrib.auth import authenticate +from django.conf import settings +from django.core.urlresolvers import get_callable +from django.core.exceptions import ImproperlyConfigured +from django.shortcuts import render_to_response +from django.template import RequestContext + +from piston import forms + +class NoAuthentication(object): + """ + Authentication handler that always returns + True, so no authentication is needed, nor + initiated (`challenge` is missing.) + """ + def is_authenticated(self, request): + return True + +class HttpBasicAuthentication(object): + """ + Basic HTTP authenticater. Synopsis: + + Authentication handlers must implement two methods: + - `is_authenticated`: Will be called when checking for + authentication. Receives a `request` object, please + set your `User` object on `request.user`, otherwise + return False (or something that evaluates to False.) + - `challenge`: In cases where `is_authenticated` returns + False, the result of this method will be returned. + This will usually be a `HttpResponse` object with + some kind of challenge headers and 401 code on it. + """ + def __init__(self, auth_func=authenticate, realm='API'): + self.auth_func = auth_func + self.realm = realm + + def is_authenticated(self, request): + auth_string = request.META.get('HTTP_AUTHORIZATION', None) + + if not auth_string: + return False + + try: + (authmeth, auth) = auth_string.split(" ", 1) + + if not authmeth.lower() == 'basic': + return False + + auth = auth.strip().decode('base64') + (username, password) = auth.split(':', 1) + except (ValueError, binascii.Error): + return False + + request.user = self.auth_func(username=username, password=password) \ + or AnonymousUser() + + return not request.user in (False, None, AnonymousUser()) + + def challenge(self): + resp = HttpResponse("Authorization Required") + resp['WWW-Authenticate'] = 'Basic realm="%s"' % self.realm + resp.status_code = 401 + return resp + + def __repr__(self): + return u'' % self.realm + +class HttpBasicSimple(HttpBasicAuthentication): + def __init__(self, realm, username, password): + self.user = User.objects.get(username=username) + self.password = password + + super(HttpBasicSimple, self).__init__(auth_func=self.hash, realm=realm) + + def hash(self, username, password): + if username == self.user.username and password == self.password: + return self.user + +def load_data_store(): + '''Load data store for OAuth Consumers, Tokens, Nonces and Resources + ''' + path = getattr(settings, 'OAUTH_DATA_STORE', 'piston.store.DataStore') + + # stolen from django.contrib.auth.load_backend + i = path.rfind('.') + module, attr = path[:i], path[i+1:] + + try: + mod = __import__(module, {}, {}, attr) + except ImportError, e: + raise ImproperlyConfigured, 'Error importing OAuth data store %s: "%s"' % (module, e) + + try: + cls = getattr(mod, attr) + except AttributeError: + raise ImproperlyConfigured, 'Module %s does not define a "%s" OAuth data store' % (module, attr) + + return cls + +# Set the datastore here. +oauth_datastore = load_data_store() + +def initialize_server_request(request): + """ + Shortcut for initialization. + """ + if request.method == "POST": #and \ +# request.META['CONTENT_TYPE'] == "application/x-www-form-urlencoded": + params = dict(request.REQUEST.items()) + else: + params = { } + + # Seems that we want to put HTTP_AUTHORIZATION into 'Authorization' + # for oauth.py to understand. Lovely. + request.META['Authorization'] = request.META.get('HTTP_AUTHORIZATION', '') + + oauth_request = oauth.OAuthRequest.from_request( + request.method, request.build_absolute_uri(), + headers=request.META, parameters=params, + query_string=request.environ.get('QUERY_STRING', '')) + + if oauth_request: + oauth_server = oauth.OAuthServer(oauth_datastore(oauth_request)) + oauth_server.add_signature_method(oauth.OAuthSignatureMethod_PLAINTEXT()) + oauth_server.add_signature_method(oauth.OAuthSignatureMethod_HMAC_SHA1()) + else: + oauth_server = None + + return oauth_server, oauth_request + +def send_oauth_error(err=None): + """ + Shortcut for sending an error. + """ + response = HttpResponse(err.message.encode('utf-8')) + response.status_code = 401 + + realm = 'OAuth' + header = oauth.build_authenticate_header(realm=realm) + + for k, v in header.iteritems(): + response[k] = v + + return response + +def oauth_request_token(request): + oauth_server, oauth_request = initialize_server_request(request) + + if oauth_server is None: + return INVALID_PARAMS_RESPONSE + try: + token = oauth_server.fetch_request_token(oauth_request) + + response = HttpResponse(token.to_string()) + except oauth.OAuthError, err: + response = send_oauth_error(err) + + return response + +def oauth_auth_view(request, token, callback, params): + form = forms.OAuthAuthenticationForm(initial={ + 'oauth_token': token.key, + 'oauth_callback': token.get_callback_url() or callback, + }) + + return render_to_response('piston/authorize_token.html', + { 'form': form }, RequestContext(request)) + +@login_required +def oauth_user_auth(request): + oauth_server, oauth_request = initialize_server_request(request) + + if oauth_request is None: + return INVALID_PARAMS_RESPONSE + + try: + token = oauth_server.fetch_request_token(oauth_request) + except oauth.OAuthError, err: + return send_oauth_error(err) + + try: + callback = oauth_server.get_callback(oauth_request) + except: + callback = None + + if request.method == "GET": + params = oauth_request.get_normalized_parameters() + + oauth_view = getattr(settings, 'OAUTH_AUTH_VIEW', None) + if oauth_view is None: + return oauth_auth_view(request, token, callback, params) + else: + return get_callable(oauth_view)(request, token, callback, params) + elif request.method == "POST": + try: + form = forms.OAuthAuthenticationForm(request.POST) + if form.is_valid(): + token = oauth_server.authorize_token(token, request.user) + args = '?'+token.to_string(only_key=True) + else: + args = '?error=%s' % 'Access not granted by user.' + print "FORM ERROR", form.errors + + if not callback: + callback = getattr(settings, 'OAUTH_CALLBACK_VIEW') + return get_callable(callback)(request, token) + + response = HttpResponseRedirect(callback+args) + + except oauth.OAuthError, err: + response = send_oauth_error(err) + else: + response = HttpResponse('Action not allowed.') + + return response + +def oauth_access_token(request): + oauth_server, oauth_request = initialize_server_request(request) + + if oauth_request is None: + return INVALID_PARAMS_RESPONSE + + try: + token = oauth_server.fetch_access_token(oauth_request) + return HttpResponse(token.to_string()) + except oauth.OAuthError, err: + return send_oauth_error(err) + +INVALID_PARAMS_RESPONSE = send_oauth_error(oauth.OAuthError('Invalid request parameters.')) + +class OAuthAuthentication(object): + """ + OAuth authentication. Based on work by Leah Culver. + """ + def __init__(self, realm='API'): + self.realm = realm + self.builder = oauth.build_authenticate_header + + def is_authenticated(self, request): + """ + Checks whether a means of specifying authentication + is provided, and if so, if it is a valid token. + + Read the documentation on `HttpBasicAuthentication` + for more information about what goes on here. + """ + if self.is_valid_request(request): + try: + consumer, token, parameters = self.validate_token(request) + except oauth.OAuthError, err: + print send_oauth_error(err) + return False + + if consumer and token: + request.user = token.user + request.consumer = consumer + request.throttle_extra = token.consumer.id + return True + + return False + + def challenge(self): + """ + Returns a 401 response with a small bit on + what OAuth is, and where to learn more about it. + + When this was written, browsers did not understand + OAuth authentication on the browser side, and hence + the helpful template we render. Maybe some day in the + future, browsers will take care of this stuff for us + and understand the 401 with the realm we give it. + """ + response = HttpResponse() + response.status_code = 401 + realm = 'API' + + for k, v in self.builder(realm=realm).iteritems(): + response[k] = v + + tmpl = loader.render_to_string('oauth/challenge.html', + { 'MEDIA_URL': settings.MEDIA_URL }) + + response.content = tmpl + + return response + + @staticmethod + def is_valid_request(request): + """ + Checks whether the required parameters are either in + the http-authorization header sent by some clients, + which is by the way the preferred method according to + OAuth spec, but otherwise fall back to `GET` and `POST`. + """ + must_have = [ 'oauth_'+s for s in [ + 'consumer_key', 'token', 'signature', + 'signature_method', 'timestamp', 'nonce' ] ] + + is_in = lambda l: all([ (p in l) for p in must_have ]) + + auth_params = request.META.get("HTTP_AUTHORIZATION", "") + req_params = request.REQUEST + + return is_in(auth_params) or is_in(req_params) + + @staticmethod + def validate_token(request, check_timestamp=True, check_nonce=True): + oauth_server, oauth_request = initialize_server_request(request) + return oauth_server.verify_request(oauth_request) + diff --git a/apps/piston/decorator.py b/apps/piston/decorator.py new file mode 100755 index 000000000..f8dc3b8ee --- /dev/null +++ b/apps/piston/decorator.py @@ -0,0 +1,186 @@ +""" +Decorator module, see +http://www.phyast.pitt.edu/~micheles/python/documentation.html +for the documentation and below for the licence. +""" + +## The basic trick is to generate the source code for the decorated function +## with the right signature and to evaluate it. +## Uncomment the statement 'print >> sys.stderr, func_src' in _decorator +## to understand what is going on. + +__all__ = ["decorator", "new_wrapper", "getinfo"] + +import inspect, sys + +try: + set +except NameError: + from sets import Set as set + +def getinfo(func): + """ + Returns an info dictionary containing: + - name (the name of the function : str) + - argnames (the names of the arguments : list) + - defaults (the values of the default arguments : tuple) + - signature (the signature : str) + - doc (the docstring : str) + - module (the module name : str) + - dict (the function __dict__ : str) + + >>> def f(self, x=1, y=2, *args, **kw): pass + + >>> info = getinfo(f) + + >>> info["name"] + 'f' + >>> info["argnames"] + ['self', 'x', 'y', 'args', 'kw'] + + >>> info["defaults"] + (1, 2) + + >>> info["signature"] + 'self, x, y, *args, **kw' + """ + assert inspect.ismethod(func) or inspect.isfunction(func) + regargs, varargs, varkwargs, defaults = inspect.getargspec(func) + argnames = list(regargs) + if varargs: + argnames.append(varargs) + if varkwargs: + argnames.append(varkwargs) + signature = inspect.formatargspec(regargs, varargs, varkwargs, defaults, + formatvalue=lambda value: "")[1:-1] + return dict(name=func.__name__, argnames=argnames, signature=signature, + defaults = func.func_defaults, doc=func.__doc__, + module=func.__module__, dict=func.__dict__, + globals=func.func_globals, closure=func.func_closure) + +# akin to functools.update_wrapper +def update_wrapper(wrapper, model, infodict=None): + infodict = infodict or getinfo(model) + try: + wrapper.__name__ = infodict['name'] + except: # Python version < 2.4 + pass + wrapper.__doc__ = infodict['doc'] + wrapper.__module__ = infodict['module'] + wrapper.__dict__.update(infodict['dict']) + wrapper.func_defaults = infodict['defaults'] + wrapper.undecorated = model + return wrapper + +def new_wrapper(wrapper, model): + """ + An improvement over functools.update_wrapper. The wrapper is a generic + callable object. It works by generating a copy of the wrapper with the + right signature and by updating the copy, not the original. + Moreovoer, 'model' can be a dictionary with keys 'name', 'doc', 'module', + 'dict', 'defaults'. + """ + if isinstance(model, dict): + infodict = model + else: # assume model is a function + infodict = getinfo(model) + assert not '_wrapper_' in infodict["argnames"], ( + '"_wrapper_" is a reserved argument name!') + src = "lambda %(signature)s: _wrapper_(%(signature)s)" % infodict + funcopy = eval(src, dict(_wrapper_=wrapper)) + return update_wrapper(funcopy, model, infodict) + +# helper used in decorator_factory +def __call__(self, func): + infodict = getinfo(func) + for name in ('_func_', '_self_'): + assert not name in infodict["argnames"], ( + '%s is a reserved argument name!' % name) + src = "lambda %(signature)s: _self_.call(_func_, %(signature)s)" + new = eval(src % infodict, dict(_func_=func, _self_=self)) + return update_wrapper(new, func, infodict) + +def decorator_factory(cls): + """ + Take a class with a ``.caller`` method and return a callable decorator + object. It works by adding a suitable __call__ method to the class; + it raises a TypeError if the class already has a nontrivial __call__ + method. + """ + attrs = set(dir(cls)) + if '__call__' in attrs: + raise TypeError('You cannot decorate a class with a nontrivial ' + '__call__ method') + if 'call' not in attrs: + raise TypeError('You cannot decorate a class without a ' + '.call method') + cls.__call__ = __call__ + return cls + +def decorator(caller): + """ + General purpose decorator factory: takes a caller function as + input and returns a decorator with the same attributes. + A caller function is any function like this:: + + def caller(func, *args, **kw): + # do something + return func(*args, **kw) + + Here is an example of usage: + + >>> @decorator + ... def chatty(f, *args, **kw): + ... print "Calling %r" % f.__name__ + ... return f(*args, **kw) + + >>> chatty.__name__ + 'chatty' + + >>> @chatty + ... def f(): pass + ... + >>> f() + Calling 'f' + + decorator can also take in input a class with a .caller method; in this + case it converts the class into a factory of callable decorator objects. + See the documentation for an example. + """ + if inspect.isclass(caller): + return decorator_factory(caller) + def _decorator(func): # the real meat is here + infodict = getinfo(func) + argnames = infodict['argnames'] + assert not ('_call_' in argnames or '_func_' in argnames), ( + 'You cannot use _call_ or _func_ as argument names!') + src = "lambda %(signature)s: _call_(_func_, %(signature)s)" % infodict + # import sys; print >> sys.stderr, src # for debugging purposes + dec_func = eval(src, dict(_func_=func, _call_=caller)) + return update_wrapper(dec_func, func, infodict) + return update_wrapper(_decorator, caller) + +if __name__ == "__main__": + import doctest; doctest.testmod() + +########################## LEGALESE ############################### + +## Redistributions of source code must retain the above copyright +## notice, this list of conditions and the following disclaimer. +## Redistributions in bytecode form must reproduce the above copyright +## notice, this list of conditions and the following disclaimer in +## the documentation and/or other materials provided with the +## distribution. + +## THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +## "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +## LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +## A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +## HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +## INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +## BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS +## OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +## ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR +## TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE +## USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH +## DAMAGE. diff --git a/apps/piston/doc.py b/apps/piston/doc.py new file mode 100644 index 000000000..63f89ecd4 --- /dev/null +++ b/apps/piston/doc.py @@ -0,0 +1,195 @@ +import inspect, handler + +from piston.handler import typemapper +from piston.handler import handler_tracker + +from django.core.urlresolvers import get_resolver, get_callable, get_script_prefix +from django.shortcuts import render_to_response +from django.template import RequestContext + +def generate_doc(handler_cls): + """ + Returns a `HandlerDocumentation` object + for the given handler. Use this to generate + documentation for your API. + """ + if not type(handler_cls) is handler.HandlerMetaClass: + raise ValueError("Give me handler, not %s" % type(handler_cls)) + + return HandlerDocumentation(handler_cls) + +class HandlerMethod(object): + def __init__(self, method, stale=False): + self.method = method + self.stale = stale + + def iter_args(self): + args, _, _, defaults = inspect.getargspec(self.method) + + for idx, arg in enumerate(args): + if arg in ('self', 'request', 'form'): + continue + + didx = len(args)-idx + + if defaults and len(defaults) >= didx: + yield (arg, str(defaults[-didx])) + else: + yield (arg, None) + + @property + def signature(self, parse_optional=True): + spec = "" + + for argn, argdef in self.iter_args(): + spec += argn + + if argdef: + spec += '=%s' % argdef + + spec += ', ' + + spec = spec.rstrip(", ") + + if parse_optional: + return spec.replace("=None", "=") + + return spec + + @property + def doc(self): + return inspect.getdoc(self.method) + + @property + def name(self): + return self.method.__name__ + + @property + def http_name(self): + if self.name == 'read': + return 'GET' + elif self.name == 'create': + return 'POST' + elif self.name == 'delete': + return 'DELETE' + elif self.name == 'update': + return 'PUT' + + def __repr__(self): + return "" % self.name + +class HandlerDocumentation(object): + def __init__(self, handler): + self.handler = handler + + def get_methods(self, include_default=False): + for method in "read create update delete".split(): + met = getattr(self.handler, method, None) + + if not met: + continue + + stale = inspect.getmodule(met) is handler + + if not self.handler.is_anonymous: + if met and (not stale or include_default): + yield HandlerMethod(met, stale) + else: + if not stale or met.__name__ == "read" \ + and 'GET' in self.allowed_methods: + + yield HandlerMethod(met, stale) + + def get_all_methods(self): + return self.get_methods(include_default=True) + + @property + def is_anonymous(self): + return handler.is_anonymous + + def get_model(self): + return getattr(self, 'model', None) + + @property + def has_anonymous(self): + return self.handler.anonymous + + @property + def anonymous(self): + if self.has_anonymous: + return HandlerDocumentation(self.handler.anonymous) + + @property + def doc(self): + return self.handler.__doc__ + + @property + def name(self): + return self.handler.__name__ + + @property + def allowed_methods(self): + return self.handler.allowed_methods + + def get_resource_uri_template(self): + """ + URI template processor. + + See http://bitworking.org/projects/URI-Templates/ + """ + def _convert(template, params=[]): + """URI template converter""" + paths = template % dict([p, "{%s}" % p] for p in params) + return u'%s%s' % (get_script_prefix(), paths) + + try: + resource_uri = self.handler.resource_uri() + + components = [None, [], {}] + + for i, value in enumerate(resource_uri): + components[i] = value + + lookup_view, args, kwargs = components + lookup_view = get_callable(lookup_view, True) + + possibilities = get_resolver(None).reverse_dict.getlist(lookup_view) + + for possibility, pattern in possibilities: + for result, params in possibility: + if args: + if len(args) != len(params): + continue + return _convert(result, params) + else: + if set(kwargs.keys()) != set(params): + continue + return _convert(result, params) + except: + return None + + resource_uri_template = property(get_resource_uri_template) + + def __repr__(self): + return u'' % self.name + +def documentation_view(request): + """ + Generic documentation view. Generates documentation + from the handlers you've defined. + """ + docs = [ ] + + for handler in handler_tracker: + docs.append(generate_doc(handler)) + + def _compare(doc1, doc2): + #handlers and their anonymous counterparts are put next to each other. + name1 = doc1.name.replace("Anonymous", "") + name2 = doc2.name.replace("Anonymous", "") + return cmp(name1, name2) + + docs.sort(_compare) + + return render_to_response('documentation.html', + { 'docs': docs }, RequestContext(request)) diff --git a/apps/piston/emitters.py b/apps/piston/emitters.py new file mode 100644 index 000000000..1ff0a52da --- /dev/null +++ b/apps/piston/emitters.py @@ -0,0 +1,441 @@ +from __future__ import generators + +import decimal, re, inspect +import copy + +try: + # yaml isn't standard with python. It shouldn't be required if it + # isn't used. + import yaml +except ImportError: + yaml = None + +# Fallback since `any` isn't in Python <2.5 +try: + any +except NameError: + def any(iterable): + for element in iterable: + if element: + return True + return False + +from django.db.models.query import QuerySet +from django.db.models import Model, permalink +from django.utils import simplejson +from django.utils.xmlutils import SimplerXMLGenerator +from django.utils.encoding import smart_unicode +from django.core.urlresolvers import reverse, NoReverseMatch +from django.core.serializers.json import DateTimeAwareJSONEncoder +from django.http import HttpResponse +from django.core import serializers + +from utils import HttpStatusCode, Mimer + +try: + import cStringIO as StringIO +except ImportError: + import StringIO + +try: + import cPickle as pickle +except ImportError: + import pickle + +# Allow people to change the reverser (default `permalink`). +reverser = permalink + +class Emitter(object): + """ + Super emitter. All other emitters should subclass + this one. It has the `construct` method which + conveniently returns a serialized `dict`. This is + usually the only method you want to use in your + emitter. See below for examples. + + `RESERVED_FIELDS` was introduced when better resource + method detection came, and we accidentially caught these + as the methods on the handler. Issue58 says that's no good. + """ + EMITTERS = { } + RESERVED_FIELDS = set([ 'read', 'update', 'create', + 'delete', 'model', 'anonymous', + 'allowed_methods', 'fields', 'exclude' ]) + + def __init__(self, payload, typemapper, handler, fields=(), anonymous=True): + self.typemapper = typemapper + self.data = payload + self.handler = handler + self.fields = fields + self.anonymous = anonymous + + if isinstance(self.data, Exception): + raise + + def method_fields(self, handler, fields): + if not handler: + return { } + + ret = dict() + + for field in fields - Emitter.RESERVED_FIELDS: + t = getattr(handler, str(field), None) + + if t and callable(t): + ret[field] = t + + return ret + + def construct(self): + """ + Recursively serialize a lot of types, and + in cases where it doesn't recognize the type, + it will fall back to Django's `smart_unicode`. + + Returns `dict`. + """ + def _any(thing, fields=()): + """ + Dispatch, all types are routed through here. + """ + ret = None + + if isinstance(thing, QuerySet): + ret = _qs(thing, fields=fields) + elif isinstance(thing, (tuple, list)): + ret = _list(thing) + elif isinstance(thing, dict): + ret = _dict(thing) + elif isinstance(thing, decimal.Decimal): + ret = str(thing) + elif isinstance(thing, Model): + ret = _model(thing, fields=fields) + elif isinstance(thing, HttpResponse): + raise HttpStatusCode(thing) + elif inspect.isfunction(thing): + if not inspect.getargspec(thing)[0]: + ret = _any(thing()) + elif hasattr(thing, '__emittable__'): + f = thing.__emittable__ + if inspect.ismethod(f) and len(inspect.getargspec(f)[0]) == 1: + ret = _any(f()) + elif repr(thing).startswith(" handler + mappings. + """ + def __new__(cls, name, bases, attrs): + new_cls = type.__new__(cls, name, bases, attrs) + + def already_registered(model, anon): + for k, (m, a) in typemapper.iteritems(): + if model == m and anon == a: + return k + + if hasattr(new_cls, 'model'): + if already_registered(new_cls.model, new_cls.is_anonymous): + if not getattr(settings, 'PISTON_IGNORE_DUPE_MODELS', False): + warnings.warn("Handler already registered for model %s, " + "you may experience inconsistent results." % new_cls.model.__name__) + + typemapper[new_cls] = (new_cls.model, new_cls.is_anonymous) + else: + typemapper[new_cls] = (None, new_cls.is_anonymous) + + if name not in ('BaseHandler', 'AnonymousBaseHandler'): + handler_tracker.append(new_cls) + + return new_cls + +class BaseHandler(object): + """ + Basehandler that gives you CRUD for free. + You are supposed to subclass this for specific + functionality. + + All CRUD methods (`read`/`update`/`create`/`delete`) + receive a request as the first argument from the + resource. Use this for checking `request.user`, etc. + """ + __metaclass__ = HandlerMetaClass + + allowed_methods = ('GET', 'POST', 'PUT', 'DELETE') + anonymous = is_anonymous = False + exclude = ( 'id', ) + fields = ( ) + + def flatten_dict(self, dct): + return dict([ (str(k), dct.get(k)) for k in dct.keys() ]) + + def has_model(self): + return hasattr(self, 'model') or hasattr(self, 'queryset') + + def queryset(self, request): + return self.model.objects.all() + + def value_from_tuple(tu, name): + for int_, n in tu: + if n == name: + return int_ + return None + + def exists(self, **kwargs): + if not self.has_model(): + raise NotImplementedError + + try: + self.model.objects.get(**kwargs) + return True + except self.model.DoesNotExist: + return False + + def read(self, request, *args, **kwargs): + if not self.has_model(): + return rc.NOT_IMPLEMENTED + + pkfield = self.model._meta.pk.name + + if pkfield in kwargs: + try: + return self.queryset(request).get(pk=kwargs.get(pkfield)) + except ObjectDoesNotExist: + return rc.NOT_FOUND + except MultipleObjectsReturned: # should never happen, since we're using a PK + return rc.BAD_REQUEST + else: + return self.queryset(request).filter(*args, **kwargs) + + def create(self, request, *args, **kwargs): + if not self.has_model(): + return rc.NOT_IMPLEMENTED + + attrs = self.flatten_dict(request.POST) + + try: + inst = self.queryset(request).get(**attrs) + return rc.DUPLICATE_ENTRY + except self.model.DoesNotExist: + inst = self.model(**attrs) + inst.save() + return inst + except self.model.MultipleObjectsReturned: + return rc.DUPLICATE_ENTRY + + def update(self, request, *args, **kwargs): + if not self.has_model(): + return rc.NOT_IMPLEMENTED + + pkfield = self.model._meta.pk.name + + if pkfield not in kwargs: + # No pk was specified + return rc.BAD_REQUEST + + try: + inst = self.queryset(request).get(pk=kwargs.get(pkfield)) + except ObjectDoesNotExist: + return rc.NOT_FOUND + except MultipleObjectsReturned: # should never happen, since we're using a PK + return rc.BAD_REQUEST + + attrs = self.flatten_dict(request.POST) + for k,v in attrs.iteritems(): + setattr( inst, k, v ) + + inst.save() + return rc.ALL_OK + + def delete(self, request, *args, **kwargs): + if not self.has_model(): + raise NotImplementedError + + try: + inst = self.queryset(request).get(*args, **kwargs) + + inst.delete() + + return rc.DELETED + except self.model.MultipleObjectsReturned: + return rc.DUPLICATE_ENTRY + except self.model.DoesNotExist: + return rc.NOT_HERE + +class AnonymousBaseHandler(BaseHandler): + """ + Anonymous handler. + """ + is_anonymous = True + allowed_methods = ('GET',) diff --git a/apps/piston/managers.py b/apps/piston/managers.py new file mode 100644 index 000000000..5694d0e28 --- /dev/null +++ b/apps/piston/managers.py @@ -0,0 +1,68 @@ +from django.db import models +from django.contrib.auth.models import User + +KEY_SIZE = 18 +SECRET_SIZE = 32 + +class KeyManager(models.Manager): + '''Add support for random key/secret generation + ''' + def generate_random_codes(self): + key = User.objects.make_random_password(length=KEY_SIZE) + secret = User.objects.make_random_password(length=SECRET_SIZE) + + while self.filter(key__exact=key, secret__exact=secret).count(): + secret = User.objects.make_random_password(length=SECRET_SIZE) + + return key, secret + + +class ConsumerManager(KeyManager): + def create_consumer(self, name, description=None, user=None): + """ + Shortcut to create a consumer with random key/secret. + """ + consumer, created = self.get_or_create(name=name) + + if user: + consumer.user = user + + if description: + consumer.description = description + + if created: + consumer.key, consumer.secret = self.generate_random_codes() + consumer.save() + + return consumer + + _default_consumer = None + +class ResourceManager(models.Manager): + _default_resource = None + + def get_default_resource(self, name): + """ + Add cache if you use a default resource. + """ + if not self._default_resource: + self._default_resource = self.get(name=name) + + return self._default_resource + +class TokenManager(KeyManager): + def create_token(self, consumer, token_type, timestamp, user=None): + """ + Shortcut to create a token with random key/secret. + """ + token, created = self.get_or_create(consumer=consumer, + token_type=token_type, + timestamp=timestamp, + user=user) + + if created: + token.key, token.secret = self.generate_random_codes() + token.save() + + return token + diff --git a/apps/piston/middleware.py b/apps/piston/middleware.py new file mode 100644 index 000000000..6d1c15518 --- /dev/null +++ b/apps/piston/middleware.py @@ -0,0 +1,20 @@ +from django.middleware.http import ConditionalGetMiddleware +from django.middleware.common import CommonMiddleware + +def compat_middleware_factory(klass): + """ + Class wrapper that only executes `process_response` + if `streaming` is not set on the `HttpResponse` object. + Django has a bad habbit of looking at the content, + which will prematurely exhaust the data source if we're + using generators or buffers. + """ + class compatwrapper(klass): + def process_response(self, req, resp): + if not hasattr(resp, 'streaming'): + return klass.process_response(self, req, resp) + return resp + return compatwrapper + +ConditionalMiddlewareCompatProxy = compat_middleware_factory(ConditionalGetMiddleware) +CommonMiddlewareCompatProxy = compat_middleware_factory(CommonMiddleware) diff --git a/apps/piston/models.py b/apps/piston/models.py new file mode 100644 index 000000000..9c93af85d --- /dev/null +++ b/apps/piston/models.py @@ -0,0 +1,150 @@ +import urllib, time, urlparse + +# Django imports +from django.db.models.signals import post_save, post_delete +from django.db import models +from django.contrib.auth.models import User +from django.contrib import admin +from django.core.mail import send_mail, mail_admins + +# Piston imports +from managers import TokenManager, ConsumerManager, ResourceManager +from signals import consumer_post_save, consumer_post_delete + +KEY_SIZE = 18 +SECRET_SIZE = 32 +VERIFIER_SIZE = 10 + +CONSUMER_STATES = ( + ('pending', 'Pending'), + ('accepted', 'Accepted'), + ('canceled', 'Canceled'), + ('rejected', 'Rejected') +) + +def generate_random(length=SECRET_SIZE): + return User.objects.make_random_password(length=length) + +class Nonce(models.Model): + token_key = models.CharField(max_length=KEY_SIZE) + consumer_key = models.CharField(max_length=KEY_SIZE) + key = models.CharField(max_length=255) + + def __unicode__(self): + return u"Nonce %s for %s" % (self.key, self.consumer_key) + +admin.site.register(Nonce) + +class Consumer(models.Model): + name = models.CharField(max_length=255) + description = models.TextField() + + key = models.CharField(max_length=KEY_SIZE) + secret = models.CharField(max_length=SECRET_SIZE) + + status = models.CharField(max_length=16, choices=CONSUMER_STATES, default='pending') + user = models.ForeignKey(User, null=True, blank=True, related_name='consumers') + + objects = ConsumerManager() + + def __unicode__(self): + return u"Consumer %s with key %s" % (self.name, self.key) + + def generate_random_codes(self): + """ + Used to generate random key/secret pairings. Use this after you've + added the other data in place of save(). + + c = Consumer() + c.name = "My consumer" + c.description = "An app that makes ponies from the API." + c.user = some_user_object + c.generate_random_codes() + """ + key = User.objects.make_random_password(length=KEY_SIZE) + secret = generate_random(SECRET_SIZE) + + while Consumer.objects.filter(key__exact=key, secret__exact=secret).count(): + secret = generate_random(SECRET_SIZE) + + self.key = key + self.secret = secret + self.save() + +admin.site.register(Consumer) + +class Token(models.Model): + REQUEST = 1 + ACCESS = 2 + TOKEN_TYPES = ((REQUEST, u'Request'), (ACCESS, u'Access')) + + key = models.CharField(max_length=KEY_SIZE) + secret = models.CharField(max_length=SECRET_SIZE) + verifier = models.CharField(max_length=VERIFIER_SIZE) + token_type = models.IntegerField(choices=TOKEN_TYPES) + timestamp = models.IntegerField(default=long(time.time())) + is_approved = models.BooleanField(default=False) + + user = models.ForeignKey(User, null=True, blank=True, related_name='tokens') + consumer = models.ForeignKey(Consumer) + + callback = models.CharField(max_length=255, null=True, blank=True) + callback_confirmed = models.BooleanField(default=False) + + objects = TokenManager() + + def __unicode__(self): + return u"%s Token %s for %s" % (self.get_token_type_display(), self.key, self.consumer) + + def to_string(self, only_key=False): + token_dict = { + 'oauth_token': self.key, + 'oauth_token_secret': self.secret, + 'oauth_callback_confirmed': 'true', + } + + if self.verifier: + token_dict.update({ 'oauth_verifier': self.verifier }) + + if only_key: + del token_dict['oauth_token_secret'] + + return urllib.urlencode(token_dict) + + def generate_random_codes(self): + key = User.objects.make_random_password(length=KEY_SIZE) + secret = generate_random(SECRET_SIZE) + + while Token.objects.filter(key__exact=key, secret__exact=secret).count(): + secret = generate_random(SECRET_SIZE) + + self.key = key + self.secret = secret + self.save() + + # -- OAuth 1.0a stuff + + def get_callback_url(self): + if self.callback and self.verifier: + # Append the oauth_verifier. + parts = urlparse.urlparse(self.callback) + scheme, netloc, path, params, query, fragment = parts[:6] + if query: + query = '%s&oauth_verifier=%s' % (query, self.verifier) + else: + query = 'oauth_verifier=%s' % self.verifier + return urlparse.urlunparse((scheme, netloc, path, params, + query, fragment)) + return self.callback + + def set_callback(self, callback): + if callback != "oob": # out of band, says "we can't do this!" + self.callback = callback + self.callback_confirmed = True + self.save() + +admin.site.register(Token) + +# Attach our signals +post_save.connect(consumer_post_save, sender=Consumer) +post_delete.connect(consumer_post_delete, sender=Consumer) diff --git a/apps/piston/oauth.py b/apps/piston/oauth.py new file mode 100644 index 000000000..3a42e20f9 --- /dev/null +++ b/apps/piston/oauth.py @@ -0,0 +1,650 @@ +""" +The MIT License + +Copyright (c) 2007 Leah Culver + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +""" + +import cgi +import urllib +import time +import random +import urlparse +import hmac +import binascii + + +VERSION = '1.0' # Hi Blaine! +HTTP_METHOD = 'GET' +SIGNATURE_METHOD = 'PLAINTEXT' + + +class OAuthError(RuntimeError): + """Generic exception class.""" + def __init__(self, message='OAuth error occured.'): + self.message = message + +def build_authenticate_header(realm=''): + """Optional WWW-Authenticate header (401 error)""" + return {'WWW-Authenticate': 'OAuth realm="%s"' % realm} + +def escape(s): + """Escape a URL including any /.""" + return urllib.quote(s, safe='~') + +def _utf8_str(s): + """Convert unicode to utf-8.""" + if isinstance(s, unicode): + return s.encode("utf-8") + else: + return str(s) + +def generate_timestamp(): + """Get seconds since epoch (UTC).""" + return int(time.time()) + +def generate_nonce(length=8): + """Generate pseudorandom number.""" + return ''.join([str(random.randint(0, 9)) for i in range(length)]) + +def generate_verifier(length=8): + """Generate pseudorandom number.""" + return ''.join([str(random.randint(0, 9)) for i in range(length)]) + + +class OAuthConsumer(object): + """Consumer of OAuth authentication. + + OAuthConsumer is a data type that represents the identity of the Consumer + via its shared secret with the Service Provider. + + """ + key = None + secret = None + + def __init__(self, key, secret): + self.key = key + self.secret = secret + + +class OAuthToken(object): + """OAuthToken is a data type that represents an End User via either an access + or request token. + + key -- the token + secret -- the token secret + + """ + key = None + secret = None + callback = None + callback_confirmed = None + verifier = None + + def __init__(self, key, secret): + self.key = key + self.secret = secret + + def set_callback(self, callback): + self.callback = callback + self.callback_confirmed = 'true' + + def set_verifier(self, verifier=None): + if verifier is not None: + self.verifier = verifier + else: + self.verifier = generate_verifier() + + def get_callback_url(self): + if self.callback and self.verifier: + # Append the oauth_verifier. + parts = urlparse.urlparse(self.callback) + scheme, netloc, path, params, query, fragment = parts[:6] + if query: + query = '%s&oauth_verifier=%s' % (query, self.verifier) + else: + query = 'oauth_verifier=%s' % self.verifier + return urlparse.urlunparse((scheme, netloc, path, params, + query, fragment)) + return self.callback + + def to_string(self): + data = { + 'oauth_token': self.key, + 'oauth_token_secret': self.secret, + } + if self.callback_confirmed is not None: + data['oauth_callback_confirmed'] = self.callback_confirmed + return urllib.urlencode(data) + + def from_string(s): + """ Returns a token from something like: + oauth_token_secret=xxx&oauth_token=xxx + """ + params = cgi.parse_qs(s, keep_blank_values=False) + key = params['oauth_token'][0] + secret = params['oauth_token_secret'][0] + token = OAuthToken(key, secret) + try: + token.callback_confirmed = params['oauth_callback_confirmed'][0] + except KeyError: + pass # 1.0, no callback confirmed. + return token + from_string = staticmethod(from_string) + + def __str__(self): + return self.to_string() + + +class OAuthRequest(object): + """OAuthRequest represents the request and can be serialized. + + OAuth parameters: + - oauth_consumer_key + - oauth_token + - oauth_signature_method + - oauth_signature + - oauth_timestamp + - oauth_nonce + - oauth_version + - oauth_verifier + ... any additional parameters, as defined by the Service Provider. + """ + parameters = None # OAuth parameters. + http_method = HTTP_METHOD + http_url = None + version = VERSION + + def __init__(self, http_method=HTTP_METHOD, http_url=None, parameters=None): + self.http_method = http_method + self.http_url = http_url + self.parameters = parameters or {} + + def set_parameter(self, parameter, value): + self.parameters[parameter] = value + + def get_parameter(self, parameter): + try: + return self.parameters[parameter] + except: + raise OAuthError('Parameter not found: %s' % parameter) + + def _get_timestamp_nonce(self): + return self.get_parameter('oauth_timestamp'), self.get_parameter( + 'oauth_nonce') + + def get_nonoauth_parameters(self): + """Get any non-OAuth parameters.""" + parameters = {} + for k, v in self.parameters.iteritems(): + # Ignore oauth parameters. + if k.find('oauth_') < 0: + parameters[k] = v + return parameters + + def to_header(self, realm=''): + """Serialize as a header for an HTTPAuth request.""" + auth_header = 'OAuth realm="%s"' % realm + # Add the oauth parameters. + if self.parameters: + for k, v in self.parameters.iteritems(): + if k[:6] == 'oauth_': + auth_header += ', %s="%s"' % (k, escape(str(v))) + return {'Authorization': auth_header} + + def to_postdata(self): + """Serialize as post data for a POST request.""" + return '&'.join(['%s=%s' % (escape(str(k)), escape(str(v))) \ + for k, v in self.parameters.iteritems()]) + + def to_url(self): + """Serialize as a URL for a GET request.""" + return '%s?%s' % (self.get_normalized_http_url(), self.to_postdata()) + + def get_normalized_parameters(self): + """Return a string that contains the parameters that must be signed.""" + params = self.parameters + try: + # Exclude the signature if it exists. + del params['oauth_signature'] + except: + pass + # Escape key values before sorting. + key_values = [(escape(_utf8_str(k)), escape(_utf8_str(v))) \ + for k,v in params.items()] + # Sort lexicographically, first after key, then after value. + key_values.sort() + # Combine key value pairs into a string. + return '&'.join(['%s=%s' % (k, v) for k, v in key_values]) + + def get_normalized_http_method(self): + """Uppercases the http method.""" + return self.http_method.upper() + + def get_normalized_http_url(self): + """Parses the URL and rebuilds it to be scheme://host/path.""" + parts = urlparse.urlparse(self.http_url) + scheme, netloc, path = parts[:3] + # Exclude default port numbers. + if scheme == 'http' and netloc[-3:] == ':80': + netloc = netloc[:-3] + elif scheme == 'https' and netloc[-4:] == ':443': + netloc = netloc[:-4] + return '%s://%s%s' % (scheme, netloc, path) + + def sign_request(self, signature_method, consumer, token): + """Set the signature parameter to the result of build_signature.""" + # Set the signature method. + self.set_parameter('oauth_signature_method', + signature_method.get_name()) + # Set the signature. + self.set_parameter('oauth_signature', + self.build_signature(signature_method, consumer, token)) + + def build_signature(self, signature_method, consumer, token): + """Calls the build signature method within the signature method.""" + return signature_method.build_signature(self, consumer, token) + + def from_request(http_method, http_url, headers=None, parameters=None, + query_string=None): + """Combines multiple parameter sources.""" + if parameters is None: + parameters = {} + + # Headers + if headers and 'Authorization' in headers: + auth_header = headers['Authorization'] + # Check that the authorization header is OAuth. + if auth_header[:6] == 'OAuth ': + auth_header = auth_header[6:] + try: + # Get the parameters from the header. + header_params = OAuthRequest._split_header(auth_header) + parameters.update(header_params) + except: + raise OAuthError('Unable to parse OAuth parameters from ' + 'Authorization header.') + + # GET or POST query string. + if query_string: + query_params = OAuthRequest._split_url_string(query_string) + parameters.update(query_params) + + # URL parameters. + param_str = urlparse.urlparse(http_url)[4] # query + url_params = OAuthRequest._split_url_string(param_str) + parameters.update(url_params) + + if parameters: + return OAuthRequest(http_method, http_url, parameters) + + return None + from_request = staticmethod(from_request) + + def from_consumer_and_token(oauth_consumer, token=None, + callback=None, verifier=None, http_method=HTTP_METHOD, + http_url=None, parameters=None): + if not parameters: + parameters = {} + + defaults = { + 'oauth_consumer_key': oauth_consumer.key, + 'oauth_timestamp': generate_timestamp(), + 'oauth_nonce': generate_nonce(), + 'oauth_version': OAuthRequest.version, + } + + defaults.update(parameters) + parameters = defaults + + if token: + parameters['oauth_token'] = token.key + parameters['oauth_callback'] = token.callback + # 1.0a support for verifier. + parameters['oauth_verifier'] = verifier + elif callback: + # 1.0a support for callback in the request token request. + parameters['oauth_callback'] = callback + + return OAuthRequest(http_method, http_url, parameters) + from_consumer_and_token = staticmethod(from_consumer_and_token) + + def from_token_and_callback(token, callback=None, http_method=HTTP_METHOD, + http_url=None, parameters=None): + if not parameters: + parameters = {} + + parameters['oauth_token'] = token.key + + if callback: + parameters['oauth_callback'] = callback + + return OAuthRequest(http_method, http_url, parameters) + from_token_and_callback = staticmethod(from_token_and_callback) + + def _split_header(header): + """Turn Authorization: header into parameters.""" + params = {} + parts = header.split(',') + for param in parts: + # Ignore realm parameter. + if param.find('realm') > -1: + continue + # Remove whitespace. + param = param.strip() + # Split key-value. + param_parts = param.split('=', 1) + # Remove quotes and unescape the value. + params[param_parts[0]] = urllib.unquote(param_parts[1].strip('\"')) + return params + _split_header = staticmethod(_split_header) + + def _split_url_string(param_str): + """Turn URL string into parameters.""" + parameters = cgi.parse_qs(param_str, keep_blank_values=False) + for k, v in parameters.iteritems(): + parameters[k] = urllib.unquote(v[0]) + return parameters + _split_url_string = staticmethod(_split_url_string) + +class OAuthServer(object): + """A worker to check the validity of a request against a data store.""" + timestamp_threshold = 300 # In seconds, five minutes. + version = VERSION + signature_methods = None + data_store = None + + def __init__(self, data_store=None, signature_methods=None): + self.data_store = data_store + self.signature_methods = signature_methods or {} + + def set_data_store(self, data_store): + self.data_store = data_store + + def get_data_store(self): + return self.data_store + + def add_signature_method(self, signature_method): + self.signature_methods[signature_method.get_name()] = signature_method + return self.signature_methods + + def fetch_request_token(self, oauth_request): + """Processes a request_token request and returns the + request token on success. + """ + try: + # Get the request token for authorization. + token = self._get_token(oauth_request, 'request') + except OAuthError: + # No token required for the initial token request. + version = self._get_version(oauth_request) + consumer = self._get_consumer(oauth_request) + try: + callback = self.get_callback(oauth_request) + except OAuthError: + callback = None # 1.0, no callback specified. + self._check_signature(oauth_request, consumer, None) + # Fetch a new token. + token = self.data_store.fetch_request_token(consumer, callback) + return token + + def fetch_access_token(self, oauth_request): + """Processes an access_token request and returns the + access token on success. + """ + version = self._get_version(oauth_request) + consumer = self._get_consumer(oauth_request) + verifier = self._get_verifier(oauth_request) + # Get the request token. + token = self._get_token(oauth_request, 'request') + self._check_signature(oauth_request, consumer, token) + new_token = self.data_store.fetch_access_token(consumer, token, verifier) + return new_token + + def verify_request(self, oauth_request): + """Verifies an api call and checks all the parameters.""" + # -> consumer and token + version = self._get_version(oauth_request) + consumer = self._get_consumer(oauth_request) + # Get the access token. + token = self._get_token(oauth_request, 'access') + self._check_signature(oauth_request, consumer, token) + parameters = oauth_request.get_nonoauth_parameters() + return consumer, token, parameters + + def authorize_token(self, token, user): + """Authorize a request token.""" + return self.data_store.authorize_request_token(token, user) + + def get_callback(self, oauth_request): + """Get the callback URL.""" + return oauth_request.get_parameter('oauth_callback') + + def build_authenticate_header(self, realm=''): + """Optional support for the authenticate header.""" + return {'WWW-Authenticate': 'OAuth realm="%s"' % realm} + + def _get_version(self, oauth_request): + """Verify the correct version request for this server.""" + try: + version = oauth_request.get_parameter('oauth_version') + except: + version = VERSION + if version and version != self.version: + raise OAuthError('OAuth version %s not supported.' % str(version)) + return version + + def _get_signature_method(self, oauth_request): + """Figure out the signature with some defaults.""" + try: + signature_method = oauth_request.get_parameter( + 'oauth_signature_method') + except: + signature_method = SIGNATURE_METHOD + try: + # Get the signature method object. + signature_method = self.signature_methods[signature_method] + except: + signature_method_names = ', '.join(self.signature_methods.keys()) + raise OAuthError('Signature method %s not supported try one of the ' + 'following: %s' % (signature_method, signature_method_names)) + + return signature_method + + def _get_consumer(self, oauth_request): + consumer_key = oauth_request.get_parameter('oauth_consumer_key') + consumer = self.data_store.lookup_consumer(consumer_key) + if not consumer: + raise OAuthError('Invalid consumer.') + return consumer + + def _get_token(self, oauth_request, token_type='access'): + """Try to find the token for the provided request token key.""" + token_field = oauth_request.get_parameter('oauth_token') + token = self.data_store.lookup_token(token_type, token_field) + if not token: + raise OAuthError('Invalid %s token: %s' % (token_type, token_field)) + return token + + def _get_verifier(self, oauth_request): + return oauth_request.get_parameter('oauth_verifier') + + def _check_signature(self, oauth_request, consumer, token): + timestamp, nonce = oauth_request._get_timestamp_nonce() + self._check_timestamp(timestamp) + self._check_nonce(consumer, token, nonce) + signature_method = self._get_signature_method(oauth_request) + try: + signature = oauth_request.get_parameter('oauth_signature') + except: + raise OAuthError('Missing signature.') + # Validate the signature. + valid_sig = signature_method.check_signature(oauth_request, consumer, + token, signature) + if not valid_sig: + key, base = signature_method.build_signature_base_string( + oauth_request, consumer, token) + raise OAuthError('Invalid signature. Expected signature base ' + 'string: %s' % base) + built = signature_method.build_signature(oauth_request, consumer, token) + + def _check_timestamp(self, timestamp): + """Verify that timestamp is recentish.""" + timestamp = int(timestamp) + now = int(time.time()) + lapsed = now - timestamp + if lapsed > self.timestamp_threshold: + raise OAuthError('Expired timestamp: given %d and now %s has a ' + 'greater difference than threshold %d' % + (timestamp, now, self.timestamp_threshold)) + + def _check_nonce(self, consumer, token, nonce): + """Verify that the nonce is uniqueish.""" + nonce = self.data_store.lookup_nonce(consumer, token, nonce) + if nonce: + raise OAuthError('Nonce already used: %s' % str(nonce)) + + +class OAuthClient(object): + """OAuthClient is a worker to attempt to execute a request.""" + consumer = None + token = None + + def __init__(self, oauth_consumer, oauth_token): + self.consumer = oauth_consumer + self.token = oauth_token + + def get_consumer(self): + return self.consumer + + def get_token(self): + return self.token + + def fetch_request_token(self, oauth_request): + """-> OAuthToken.""" + raise NotImplementedError + + def fetch_access_token(self, oauth_request): + """-> OAuthToken.""" + raise NotImplementedError + + def access_resource(self, oauth_request): + """-> Some protected resource.""" + raise NotImplementedError + + +class OAuthDataStore(object): + """A database abstraction used to lookup consumers and tokens.""" + + def lookup_consumer(self, key): + """-> OAuthConsumer.""" + raise NotImplementedError + + def lookup_token(self, oauth_consumer, token_type, token_token): + """-> OAuthToken.""" + raise NotImplementedError + + def lookup_nonce(self, oauth_consumer, oauth_token, nonce): + """-> OAuthToken.""" + raise NotImplementedError + + def fetch_request_token(self, oauth_consumer, oauth_callback): + """-> OAuthToken.""" + raise NotImplementedError + + def fetch_access_token(self, oauth_consumer, oauth_token, oauth_verifier): + """-> OAuthToken.""" + raise NotImplementedError + + def authorize_request_token(self, oauth_token, user): + """-> OAuthToken.""" + raise NotImplementedError + + +class OAuthSignatureMethod(object): + """A strategy class that implements a signature method.""" + def get_name(self): + """-> str.""" + raise NotImplementedError + + def build_signature_base_string(self, oauth_request, oauth_consumer, oauth_token): + """-> str key, str raw.""" + raise NotImplementedError + + def build_signature(self, oauth_request, oauth_consumer, oauth_token): + """-> str.""" + raise NotImplementedError + + def check_signature(self, oauth_request, consumer, token, signature): + built = self.build_signature(oauth_request, consumer, token) + return built == signature + + +class OAuthSignatureMethod_HMAC_SHA1(OAuthSignatureMethod): + + def get_name(self): + return 'HMAC-SHA1' + + def build_signature_base_string(self, oauth_request, consumer, token): + sig = ( + escape(oauth_request.get_normalized_http_method()), + escape(oauth_request.get_normalized_http_url()), + escape(oauth_request.get_normalized_parameters()), + ) + + key = '%s&' % escape(consumer.secret) + if token: + key += escape(token.secret) + raw = '&'.join(sig) + return key, raw + + def build_signature(self, oauth_request, consumer, token): + """Builds the base signature string.""" + key, raw = self.build_signature_base_string(oauth_request, consumer, + token) + + # HMAC object. + try: + import hashlib # 2.5 + hashed = hmac.new(key, raw, hashlib.sha1) + except: + import sha # Deprecated + hashed = hmac.new(key, raw, sha) + + # Calculate the digest base 64. + return binascii.b2a_base64(hashed.digest())[:-1] + + +class OAuthSignatureMethod_PLAINTEXT(OAuthSignatureMethod): + + def get_name(self): + return 'PLAINTEXT' + + def build_signature_base_string(self, oauth_request, consumer, token): + """Concatenates the consumer key and secret.""" + sig = '%s&' % escape(consumer.secret) + if token: + sig = sig + escape(token.secret) + return sig, sig + + def build_signature(self, oauth_request, consumer, token): + key, raw = self.build_signature_base_string(oauth_request, consumer, + token) + return key \ No newline at end of file diff --git a/apps/piston/resource.py b/apps/piston/resource.py new file mode 100644 index 000000000..40f065d05 --- /dev/null +++ b/apps/piston/resource.py @@ -0,0 +1,260 @@ +import sys, inspect + +from django.http import (HttpResponse, Http404, HttpResponseNotAllowed, + HttpResponseForbidden, HttpResponseServerError) +from django.views.debug import ExceptionReporter +from django.views.decorators.vary import vary_on_headers +from django.conf import settings +from django.core.mail import send_mail, EmailMessage +from django.db.models.query import QuerySet +from django.http import Http404 + +from emitters import Emitter +from handler import typemapper +from doc import HandlerMethod +from authentication import NoAuthentication +from utils import coerce_put_post, FormValidationError, HttpStatusCode +from utils import rc, format_error, translate_mime, MimerDataException + +CHALLENGE = object() + +class Resource(object): + """ + Resource. Create one for your URL mappings, just + like you would with Django. Takes one argument, + the handler. The second argument is optional, and + is an authentication handler. If not specified, + `NoAuthentication` will be used by default. + """ + callmap = { 'GET': 'read', 'POST': 'create', + 'PUT': 'update', 'DELETE': 'delete' } + + def __init__(self, handler, authentication=None): + if not callable(handler): + raise AttributeError, "Handler not callable." + + self.handler = handler() + + if not authentication: + self.authentication = (NoAuthentication(),) + elif isinstance(authentication, (list, tuple)): + self.authentication = authentication + else: + self.authentication = (authentication,) + + # Erroring + self.email_errors = getattr(settings, 'PISTON_EMAIL_ERRORS', True) + self.display_errors = getattr(settings, 'PISTON_DISPLAY_ERRORS', True) + self.stream = getattr(settings, 'PISTON_STREAM_OUTPUT', False) + + def determine_emitter(self, request, *args, **kwargs): + """ + Function for determening which emitter to use + for output. It lives here so you can easily subclass + `Resource` in order to change how emission is detected. + + You could also check for the `Accept` HTTP header here, + since that pretty much makes sense. Refer to `Mimer` for + that as well. + """ + em = kwargs.pop('emitter_format', None) + + if not em: + em = request.GET.get('format', 'json') + + return em + + @property + def anonymous(self): + """ + Gets the anonymous handler. Also tries to grab a class + if the `anonymous` value is a string, so that we can define + anonymous handlers that aren't defined yet (like, when + you're subclassing your basehandler into an anonymous one.) + """ + if hasattr(self.handler, 'anonymous'): + anon = self.handler.anonymous + + if callable(anon): + return anon + + for klass in typemapper.keys(): + if anon == klass.__name__: + return klass + + return None + + def authenticate(self, request, rm): + actor, anonymous = False, True + + for authenticator in self.authentication: + if not authenticator.is_authenticated(request): + if self.anonymous and \ + rm in self.anonymous.allowed_methods: + + actor, anonymous = self.anonymous(), True + else: + actor, anonymous = authenticator.challenge, CHALLENGE + else: + return self.handler, self.handler.is_anonymous + + return actor, anonymous + + @vary_on_headers('Authorization') + def __call__(self, request, *args, **kwargs): + """ + NB: Sends a `Vary` header so we don't cache requests + that are different (OAuth stuff in `Authorization` header.) + """ + rm = request.method.upper() + + # Django's internal mechanism doesn't pick up + # PUT request, so we trick it a little here. + if rm == "PUT": + coerce_put_post(request) + + actor, anonymous = self.authenticate(request, rm) + + if anonymous is CHALLENGE: + return actor() + else: + handler = actor + + # Translate nested datastructs into `request.data` here. + if rm in ('POST', 'PUT'): + try: + translate_mime(request) + except MimerDataException: + return rc.BAD_REQUEST + + if not rm in handler.allowed_methods: + return HttpResponseNotAllowed(handler.allowed_methods) + + meth = getattr(handler, self.callmap.get(rm), None) + + if not meth: + raise Http404 + + # Support emitter both through (?P) and ?format=emitter. + em_format = self.determine_emitter(request, *args, **kwargs) + + kwargs.pop('emitter_format', None) + + # Clean up the request object a bit, since we might + # very well have `oauth_`-headers in there, and we + # don't want to pass these along to the handler. + request = self.cleanup_request(request) + + try: + result = meth(request, *args, **kwargs) + except FormValidationError, e: + resp = rc.BAD_REQUEST + resp.write(' '+str(e.form.errors)) + + return resp + except TypeError, e: + result = rc.BAD_REQUEST + hm = HandlerMethod(meth) + sig = hm.signature + + msg = 'Method signature does not match.\n\n' + + if sig: + msg += 'Signature should be: %s' % sig + else: + msg += 'Resource does not expect any parameters.' + + if self.display_errors: + msg += '\n\nException was: %s' % str(e) + + result.content = format_error(msg) + except Http404: + return rc.NOT_FOUND + except HttpStatusCode, e: + return e.response + except Exception, e: + """ + On errors (like code errors), we'd like to be able to + give crash reports to both admins and also the calling + user. There's two setting parameters for this: + + Parameters:: + - `PISTON_EMAIL_ERRORS`: Will send a Django formatted + error email to people in `settings.ADMINS`. + - `PISTON_DISPLAY_ERRORS`: Will return a simple traceback + to the caller, so he can tell you what error they got. + + If `PISTON_DISPLAY_ERRORS` is not enabled, the caller will + receive a basic "500 Internal Server Error" message. + """ + exc_type, exc_value, tb = sys.exc_info() + rep = ExceptionReporter(request, exc_type, exc_value, tb.tb_next) + if self.email_errors: + self.email_exception(rep) + if self.display_errors: + return HttpResponseServerError( + format_error('\n'.join(rep.format_exception()))) + else: + raise + + emitter, ct = Emitter.get(em_format) + fields = handler.fields + if hasattr(handler, 'list_fields') and ( + isinstance(result, list) or isinstance(result, QuerySet)): + fields = handler.list_fields + + srl = emitter(result, typemapper, handler, fields, anonymous) + + try: + """ + Decide whether or not we want a generator here, + or we just want to buffer up the entire result + before sending it to the client. Won't matter for + smaller datasets, but larger will have an impact. + """ + if self.stream: stream = srl.stream_render(request) + else: stream = srl.render(request) + + if not isinstance(stream, HttpResponse): + resp = HttpResponse(stream, mimetype=ct) + else: + resp = stream + + resp.streaming = self.stream + + return resp + except HttpStatusCode, e: + return e.response + + @staticmethod + def cleanup_request(request): + """ + Removes `oauth_` keys from various dicts on the + request object, and returns the sanitized version. + """ + for method_type in ('GET', 'PUT', 'POST', 'DELETE'): + block = getattr(request, method_type, { }) + + if True in [ k.startswith("oauth_") for k in block.keys() ]: + sanitized = block.copy() + + for k in sanitized.keys(): + if k.startswith("oauth_"): + sanitized.pop(k) + + setattr(request, method_type, sanitized) + + return request + + # -- + + def email_exception(self, reporter): + subject = "Piston crash report" + html = reporter.get_traceback_html() + + message = EmailMessage(settings.EMAIL_SUBJECT_PREFIX+subject, + html, settings.SERVER_EMAIL, + [ admin[1] for admin in settings.ADMINS ]) + + message.content_subtype = 'html' + message.send(fail_silently=True) diff --git a/apps/piston/signals.py b/apps/piston/signals.py new file mode 100644 index 000000000..133be13fe --- /dev/null +++ b/apps/piston/signals.py @@ -0,0 +1,14 @@ +# Django imports +import django.dispatch + +# Piston imports +from utils import send_consumer_mail + +def consumer_post_save(sender, instance, created, **kwargs): + send_consumer_mail(instance) + +def consumer_post_delete(sender, instance, **kwargs): + instance.status = 'canceled' + send_consumer_mail(instance) + + diff --git a/apps/piston/store.py b/apps/piston/store.py new file mode 100644 index 000000000..787791ab8 --- /dev/null +++ b/apps/piston/store.py @@ -0,0 +1,75 @@ +import oauth + +from models import Nonce, Token, Consumer +from models import generate_random, VERIFIER_SIZE + +class DataStore(oauth.OAuthDataStore): + """Layer between Python OAuth and Django database.""" + def __init__(self, oauth_request): + self.signature = oauth_request.parameters.get('oauth_signature', None) + self.timestamp = oauth_request.parameters.get('oauth_timestamp', None) + self.scope = oauth_request.parameters.get('scope', 'all') + + def lookup_consumer(self, key): + try: + self.consumer = Consumer.objects.get(key=key) + return self.consumer + except Consumer.DoesNotExist: + return None + + def lookup_token(self, token_type, token): + if token_type == 'request': + token_type = Token.REQUEST + elif token_type == 'access': + token_type = Token.ACCESS + try: + self.request_token = Token.objects.get(key=token, + token_type=token_type) + return self.request_token + except Token.DoesNotExist: + return None + + def lookup_nonce(self, oauth_consumer, oauth_token, nonce): + if oauth_token is None: + return None + nonce, created = Nonce.objects.get_or_create(consumer_key=oauth_consumer.key, + token_key=oauth_token.key, + key=nonce) + if created: + return None + else: + return nonce.key + + def fetch_request_token(self, oauth_consumer, oauth_callback): + if oauth_consumer.key == self.consumer.key: + self.request_token = Token.objects.create_token(consumer=self.consumer, + token_type=Token.REQUEST, + timestamp=self.timestamp) + + if oauth_callback: + self.request_token.set_callback(oauth_callback) + + return self.request_token + return None + + def fetch_access_token(self, oauth_consumer, oauth_token, oauth_verifier): + if oauth_consumer.key == self.consumer.key \ + and oauth_token.key == self.request_token.key \ + and oauth_verifier == self.request_token.verifier \ + and self.request_token.is_approved: + self.access_token = Token.objects.create_token(consumer=self.consumer, + token_type=Token.ACCESS, + timestamp=self.timestamp, + user=self.request_token.user) + return self.access_token + return None + + def authorize_request_token(self, oauth_token, user): + if oauth_token.key == self.request_token.key: + # authorize the request token in the store + self.request_token.is_approved = True + self.request_token.user = user + self.request_token.verifier = generate_random(VERIFIER_SIZE) + self.request_token.save() + return self.request_token + return None \ No newline at end of file diff --git a/apps/piston/templates/documentation.html b/apps/piston/templates/documentation.html new file mode 100644 index 000000000..d7b18300d --- /dev/null +++ b/apps/piston/templates/documentation.html @@ -0,0 +1,55 @@ +{% load markup %} + + + + + Piston generated documentation + + + + +

API Documentation

+ + {% for doc in docs %} + +

{{ doc.name|cut:"Handler" }}:

+ +

+ {{ doc.get_doc|default:""|restructuredtext }} +

+ +

+ URL: {{ doc.get_resource_uri_template }} +

+ +

+ Accepted methods: {% for meth in doc.allowed_methods %}{{ meth }}{% if not forloop.last %}, {% endif %}{% endfor %} +

+ +
+ {% for method in doc.get_all_methods %} + +
+ method {{ method.name }}({{ method.signature }}){% if method.stale %} - inherited{% else %}:{% endif %} + +
+ + {% if method.get_doc %} +
+ {{ method.get_doc|default:""|restructuredtext }} +
+ {% endif %} + + {% endfor %} +
+ + {% endfor %} + + diff --git a/apps/piston/templates/piston/authorize_token.html b/apps/piston/templates/piston/authorize_token.html new file mode 100644 index 000000000..dae840e02 --- /dev/null +++ b/apps/piston/templates/piston/authorize_token.html @@ -0,0 +1,15 @@ + + + + Authorize Token + + +

Authorize Token

+ +
+ {{ form.as_table }} +
+ + + diff --git a/apps/piston/test.py b/apps/piston/test.py new file mode 100644 index 000000000..57eda72be --- /dev/null +++ b/apps/piston/test.py @@ -0,0 +1,62 @@ +# Django imports +import django.test.client as client +import django.test as test +from django.utils.http import urlencode + +# Piston imports +from piston import oauth +from piston.models import Consumer, Token + +# 3rd/Python party imports +import httplib2, urllib, cgi + +URLENCODED_FORM_CONTENT = 'application/x-www-form-urlencoded' + +class OAuthClient(client.Client): + def __init__(self, consumer, token): + self.token = oauth.OAuthToken(token.key, token.secret) + self.consumer = oauth.OAuthConsumer(consumer.key, consumer.secret) + self.signature = oauth.OAuthSignatureMethod_HMAC_SHA1() + + super(OAuthClient, self).__init__() + + def request(self, **request): + # Figure out parameters from request['QUERY_STRING'] and FakePayload + params = {} + if request['REQUEST_METHOD'] in ('POST', 'PUT'): + if request['CONTENT_TYPE'] == URLENCODED_FORM_CONTENT: + payload = request['wsgi.input'].read() + request['wsgi.input'] = client.FakePayload(payload) + params = cgi.parse_qs(payload) + + url = "http://testserver" + request['PATH_INFO'] + + req = oauth.OAuthRequest.from_consumer_and_token( + self.consumer, token=self.token, + http_method=request['REQUEST_METHOD'], http_url=url, + parameters=params + ) + + req.sign_request(self.signature, self.consumer, self.token) + headers = req.to_header() + request['HTTP_AUTHORIZATION'] = headers['Authorization'] + + return super(OAuthClient, self).request(**request) + + def post(self, path, data={}, content_type=None, follow=False, **extra): + if content_type is None: + content_type = URLENCODED_FORM_CONTENT + + if isinstance(data, dict): + data = urlencode(data) + + return super(OAuthClient, self).post(path, data, content_type, follow, **extra) + +class TestCase(test.TestCase): + pass + +class OAuthTestCase(TestCase): + @property + def oauth(self): + return OAuthClient(self.consumer, self.token) + diff --git a/apps/piston/tests.py b/apps/piston/tests.py new file mode 100644 index 000000000..92d14ee3b --- /dev/null +++ b/apps/piston/tests.py @@ -0,0 +1,44 @@ +# Django imports +from django.core import mail +from django.contrib.auth.models import User +from django.conf import settings + +# Piston imports +from test import TestCase +from models import Consumer + +class ConsumerTest(TestCase): + fixtures = ['models.json'] + + def setUp(self): + self.consumer = Consumer() + self.consumer.name = "Piston Test Consumer" + self.consumer.description = "A test consumer for Piston." + self.consumer.user = User.objects.get(pk=3) + self.consumer.generate_random_codes() + + def test_create_pending(self): + """ Ensure creating a pending Consumer sends proper emails """ + # If it's pending we should have two messages in the outbox; one + # to the consumer and one to the site admins. + if len(settings.ADMINS): + self.assertEquals(len(mail.outbox), 2) + else: + self.assertEquals(len(mail.outbox), 1) + + expected = "Your API Consumer for example.com is awaiting approval." + self.assertEquals(mail.outbox[0].subject, expected) + + def test_delete_consumer(self): + """ Ensure deleting a Consumer sends a cancel email """ + + # Clear out the outbox before we test for the cancel email. + mail.outbox = [] + + # Delete the consumer, which should fire off the cancel email. + self.consumer.delete() + + self.assertEquals(len(mail.outbox), 1) + expected = "Your API Consumer for example.com has been canceled." + self.assertEquals(mail.outbox[0].subject, expected) + diff --git a/apps/piston/utils.py b/apps/piston/utils.py new file mode 100644 index 000000000..8d0cce8fd --- /dev/null +++ b/apps/piston/utils.py @@ -0,0 +1,313 @@ +import time +from django.http import HttpResponseNotAllowed, HttpResponseForbidden, HttpResponse, HttpResponseBadRequest +from django.core.urlresolvers import reverse +from django.core.cache import cache +from django import get_version as django_version +from django.core.mail import send_mail, mail_admins +from django.conf import settings +from django.utils.translation import ugettext as _ +from django.template import loader, TemplateDoesNotExist +from django.contrib.sites.models import Site +from decorator import decorator + +from datetime import datetime, timedelta + +__version__ = '0.2.3rc1' + +def get_version(): + return __version__ + +def format_error(error): + return u"Piston/%s (Django %s) crash report:\n\n%s" % \ + (get_version(), django_version(), error) + +class rc_factory(object): + """ + Status codes. + """ + CODES = dict(ALL_OK = ('OK', 200), + CREATED = ('Created', 201), + DELETED = ('', 204), # 204 says "Don't send a body!" + BAD_REQUEST = ('Bad Request', 400), + FORBIDDEN = ('Forbidden', 401), + NOT_FOUND = ('Not Found', 404), + DUPLICATE_ENTRY = ('Conflict/Duplicate', 409), + NOT_HERE = ('Gone', 410), + INTERNAL_ERROR = ('Internal Error', 500), + NOT_IMPLEMENTED = ('Not Implemented', 501), + THROTTLED = ('Throttled', 503)) + + def __getattr__(self, attr): + """ + Returns a fresh `HttpResponse` when getting + an "attribute". This is backwards compatible + with 0.2, which is important. + """ + try: + (r, c) = self.CODES.get(attr) + except TypeError: + raise AttributeError(attr) + + return HttpResponse(r, content_type='text/plain', status=c) + +rc = rc_factory() + +class FormValidationError(Exception): + def __init__(self, form): + self.form = form + +class HttpStatusCode(Exception): + def __init__(self, response): + self.response = response + +def validate(v_form, operation='POST'): + @decorator + def wrap(f, self, request, *a, **kwa): + form = v_form(getattr(request, operation)) + + if form.is_valid(): + return f(self, request, *a, **kwa) + else: + raise FormValidationError(form) + return wrap + +def throttle(max_requests, timeout=60*60, extra=''): + """ + Simple throttling decorator, caches + the amount of requests made in cache. + + If used on a view where users are required to + log in, the username is used, otherwise the + IP address of the originating request is used. + + Parameters:: + - `max_requests`: The maximum number of requests + - `timeout`: The timeout for the cache entry (default: 1 hour) + """ + @decorator + def wrap(f, self, request, *args, **kwargs): + if request.user.is_authenticated(): + ident = request.user.username + else: + ident = request.META.get('REMOTE_ADDR', None) + + if hasattr(request, 'throttle_extra'): + """ + Since we want to be able to throttle on a per- + application basis, it's important that we realize + that `throttle_extra` might be set on the request + object. If so, append the identifier name with it. + """ + ident += ':%s' % str(request.throttle_extra) + + if ident: + """ + Preferrably we'd use incr/decr here, since they're + atomic in memcached, but it's in django-trunk so we + can't use it yet. If someone sees this after it's in + stable, you can change it here. + """ + ident += ':%s' % extra + + now = time.time() + count, expiration = cache.get(ident, (1, None)) + + if expiration is None: + expiration = now + timeout + + if count >= max_requests and expiration > now: + t = rc.THROTTLED + wait = int(expiration - now) + t.content = 'Throttled, wait %d seconds.' % wait + t['Retry-After'] = wait + return t + + cache.set(ident, (count+1, expiration), (expiration - now)) + + return f(self, request, *args, **kwargs) + return wrap + +def coerce_put_post(request): + """ + Django doesn't particularly understand REST. + In case we send data over PUT, Django won't + actually look at the data and load it. We need + to twist its arm here. + + The try/except abominiation here is due to a bug + in mod_python. This should fix it. + """ + if request.method == "PUT": + try: + request.method = "POST" + request._load_post_and_files() + request.method = "PUT" + except AttributeError: + request.META['REQUEST_METHOD'] = 'POST' + request._load_post_and_files() + request.META['REQUEST_METHOD'] = 'PUT' + + request.PUT = request.POST + + +class MimerDataException(Exception): + """ + Raised if the content_type and data don't match + """ + pass + +class Mimer(object): + TYPES = dict() + + def __init__(self, request): + self.request = request + + def is_multipart(self): + content_type = self.content_type() + + if content_type is not None: + return content_type.lstrip().startswith('multipart') + + return False + + def loader_for_type(self, ctype): + """ + Gets a function ref to deserialize content + for a certain mimetype. + """ + for loadee, mimes in Mimer.TYPES.iteritems(): + for mime in mimes: + if ctype.startswith(mime): + return loadee + + def content_type(self): + """ + Returns the content type of the request in all cases where it is + different than a submitted form - application/x-www-form-urlencoded + """ + type_formencoded = "application/x-www-form-urlencoded" + + ctype = self.request.META.get('CONTENT_TYPE', type_formencoded) + + if type_formencoded in ctype: + return None + + return ctype + + def translate(self): + """ + Will look at the `Content-type` sent by the client, and maybe + deserialize the contents into the format they sent. This will + work for JSON, YAML, XML and Pickle. Since the data is not just + key-value (and maybe just a list), the data will be placed on + `request.data` instead, and the handler will have to read from + there. + + It will also set `request.content_type` so the handler has an easy + way to tell what's going on. `request.content_type` will always be + None for form-encoded and/or multipart form data (what your browser sends.) + """ + ctype = self.content_type() + self.request.content_type = ctype + + if not self.is_multipart() and ctype: + loadee = self.loader_for_type(ctype) + + if loadee: + try: + self.request.data = loadee(self.request.raw_post_data) + + # Reset both POST and PUT from request, as its + # misleading having their presence around. + self.request.POST = self.request.PUT = dict() + except (TypeError, ValueError): + # This also catches if loadee is None. + raise MimerDataException + else: + self.request.data = None + + return self.request + + @classmethod + def register(cls, loadee, types): + cls.TYPES[loadee] = types + + @classmethod + def unregister(cls, loadee): + return cls.TYPES.pop(loadee) + +def translate_mime(request): + request = Mimer(request).translate() + +def require_mime(*mimes): + """ + Decorator requiring a certain mimetype. There's a nifty + helper called `require_extended` below which requires everything + we support except for post-data via form. + """ + @decorator + def wrap(f, self, request, *args, **kwargs): + m = Mimer(request) + realmimes = set() + + rewrite = { 'json': 'application/json', + 'yaml': 'application/x-yaml', + 'xml': 'text/xml', + 'pickle': 'application/python-pickle' } + + for idx, mime in enumerate(mimes): + realmimes.add(rewrite.get(mime, mime)) + + if not m.content_type() in realmimes: + return rc.BAD_REQUEST + + return f(self, request, *args, **kwargs) + return wrap + +require_extended = require_mime('json', 'yaml', 'xml', 'pickle') + +def send_consumer_mail(consumer): + """ + Send a consumer an email depending on what their status is. + """ + try: + subject = settings.PISTON_OAUTH_EMAIL_SUBJECTS[consumer.status] + except AttributeError: + subject = "Your API Consumer for %s " % Site.objects.get_current().name + if consumer.status == "accepted": + subject += "was accepted!" + elif consumer.status == "canceled": + subject += "has been canceled." + elif consumer.status == "rejected": + subject += "has been rejected." + else: + subject += "is awaiting approval." + + template = "piston/mails/consumer_%s.txt" % consumer.status + + try: + body = loader.render_to_string(template, + { 'consumer' : consumer, 'user' : consumer.user }) + except TemplateDoesNotExist: + """ + They haven't set up the templates, which means they might not want + these emails sent. + """ + return + + try: + sender = settings.PISTON_FROM_EMAIL + except AttributeError: + sender = settings.DEFAULT_FROM_EMAIL + + if consumer.user: + send_mail(_(subject), body, sender, [consumer.user.email], fail_silently=True) + + if consumer.status == 'pending' and len(settings.ADMINS): + mail_admins(_(subject), body, fail_silently=True) + + if settings.DEBUG and consumer.user: + print "Mail being sent, to=%s" % consumer.user.email + print "Subject: %s" % _(subject) + print body + -- 2.20.1