├── .gitignore ├── LICENSE.txt ├── README.rst ├── examples ├── consumer │ └── consumer.py ├── django_app │ ├── api │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── models.py │ │ ├── tests.py │ │ └── views.py │ ├── django_app │ │ ├── __init__.py │ │ ├── settings.py │ │ ├── urls.py │ │ └── wsgi.py │ └── manage.py ├── flask_app │ ├── app.py │ └── keys.txt └── twisted_app │ └── app.py ├── setup.py └── webservices ├── __init__.py ├── async.py ├── exceptions.py ├── models.py ├── sync.py └── tests.py /.gitignore: -------------------------------------------------------------------------------- 1 | /*.egg-info/ 2 | /*.egg/ 3 | *.pyc 4 | .* 5 | /htmlcov/ 6 | /examples/env/ 7 | /examples/django_app/keys.sqlite 8 | /dist/ 9 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012, Jonas Obrist 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the name of the Jonas Obrist nor the 12 | names of its contributors may be used to endorse or promote products 13 | derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL JONAS OBRIST BE LIABLE FOR ANY 19 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ########### 2 | Webservices 3 | ########### 4 | 5 | Build and consume web services (aka APIs) in Python. 6 | 7 | ******** 8 | Features 9 | ******** 10 | 11 | * Providers that work with Django, Flask and Twisted 12 | * Everything is signed (using itsdangerous) 13 | * Synchronous consumer (framework independant) 14 | * Asynchronous consumer (powered by Twisted) 15 | 16 | 17 | ************ 18 | Installation 19 | ************ 20 | 21 | Django (provider/consumer) 22 | ========================== 23 | 24 | ``pip install webservices[django]`` 25 | 26 | 27 | Flask (provider/consumer) 28 | ========================= 29 | 30 | ``pip install webservices[flask]`` 31 | 32 | Twisted (provider/consumer) 33 | =========================== 34 | 35 | ``pip install webservices[twisted]`` 36 | 37 | Synchronous consumer only 38 | ========================= 39 | 40 | ``pip install webservices[consumer]`` 41 | 42 | 43 | ********** 44 | Quickstart 45 | ********** 46 | 47 | We'll write an API that greets you with your name (or 'hello world' if not name 48 | is provided). 49 | 50 | Provider 51 | ======== 52 | 53 | Django 54 | ------ 55 | 56 | We assume you have a setting ``API_KEYS`` which is a dictionary of public keys 57 | mapping to private keys. 58 | 59 | ``myapi/urls.py``:: 60 | 61 | from django.conf.urls import url, patterns 62 | from webservices.sync import provider_for_django 63 | from myapi.views import HelloProvider 64 | 65 | urlpatterns = patterns('', 66 | url(r'hello/$', provider_for_django(HelloProvider())), 67 | ) 68 | 69 | Your ``myapi/views.py``:: 70 | 71 | from django.conf import settings 72 | from webservices.models import Provider 73 | 74 | class HelloProvider(Provider): 75 | def get_private_key(self, public_key): 76 | return settings.API_KEYS.get(public_key) 77 | 78 | def provide(self, data): 79 | name = data.get('name', 'world') 80 | return {'greeting': u'hello %s' % name} 81 | 82 | 83 | Flask 84 | ----- 85 | 86 | 87 | ``app.py``:: 88 | 89 | from flask import Flask 90 | from webservices.sync import provider_for_flask 91 | from webservices.models import Provider 92 | 93 | app = Flask(__name__) 94 | 95 | API_KEYS = { 96 | 'publickey': 'privatekey', # your keys here 97 | } 98 | 99 | class HelloProvider(Provider): 100 | def get_private_key(self, public_key): 101 | return API_KEYS.get(public_key) 102 | 103 | def provide(self, data): 104 | name = data.get('name', 'world') 105 | return {'greeting': u'hello %s' % name} 106 | 107 | provider_for_flask(app, '/hello/', HelloProvider()) 108 | 109 | 110 | Twisted 111 | ------- 112 | 113 | ``app.py``:: 114 | 115 | from twisted.internet import reactor 116 | from twisted.web.server import Site 117 | from webservices.async import provider_for_twisted 118 | from webservices.models import Provider 119 | 120 | API_KEYS = { 121 | 'publickey': 'privatekey', # your keys here 122 | } 123 | 124 | class HelloProvider(Provider): 125 | def get_private_key(self, public_key): 126 | return API_KEYS.get(public_key) 127 | 128 | def provide(self, data): 129 | name = data.get('name', 'world') 130 | return {'greeting': u'hello %s' % name} 131 | 132 | resource = provider_for_twisted(HelloProvider()) 133 | 134 | site = Site(resource) 135 | reactor.listenTCP(80, site) 136 | reactor.run() 137 | 138 | 139 | Noticed how the provider is basically the same for all three (other than 140 | ``get_private_key``)? Neat, right? 141 | 142 | 143 | Handling errors 144 | --------------- 145 | 146 | To log errors (for example using raven) you can implement the ``report_exception`` method on ``Provider`` classes. 147 | This method is called whenever the ``provide`` method throws an exception. It takes no arguments. 148 | 149 | 150 | Consumer 151 | ======== 152 | 153 | Synchronous 154 | ----------- 155 | 156 | To consume that code (assuming it's hosted on 'https://api.example.org'):: 157 | 158 | from webservices.sync import SyncConsumer 159 | 160 | consumer = SyncConsumer('https://api.example.org', 'mypublickey', 'myprivatekey') 161 | result = consumer.consume('/hello/', {'name': 'webservices') 162 | print result # prints 'hello webservices' 163 | 164 | 165 | Asynchronous 166 | ------------ 167 | 168 | Same as above, but async:: 169 | 170 | from webservices.async import TwistedConsumer 171 | from twisted.internet import reactor 172 | 173 | def callback(result): 174 | print result # prints 'hello webserivces' 175 | reactor.stop() 176 | 177 | consumer = TwistedConsumer('https://api.example.org', 'mypublickey', 'myprivatekey') 178 | deferred = consumer.consume('/hello/', {'name': 'webservices') 179 | deferred.addCallback(callback) 180 | 181 | reactor.run() 182 | 183 | 184 | Data Source Name 185 | ---------------- 186 | 187 | You can create consumers from Data Source Names (eg ``'http://public_key:private_key@api.example.org'``) using the 188 | ``from_dsn`` classmethod on consumers. 189 | 190 | Example: 191 | 192 | consumer = SyncConsumer.from_dsn('https://public_key:private_key@api.example.org') 193 | 194 | 195 | ******* 196 | License 197 | ******* 198 | 199 | This code is licensed under the 3-clause BSD license, see LICENSE.txt. 200 | -------------------------------------------------------------------------------- /examples/consumer/consumer.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from webservices.sync import SyncConsumer 4 | 5 | 6 | def main(): 7 | parser = argparse.ArgumentParser() 8 | parser.add_argument('public_key') 9 | parser.add_argument('private_key') 10 | parser.add_argument('name') 11 | parser.add_argument('--host', default='http://localhost:8000/') 12 | args = parser.parse_args() 13 | consumer = SyncConsumer(args.host, args.public_key, args.private_key) 14 | print(consumer.consume('/', {'name': args.name})['greeting']) 15 | 16 | if __name__ == '__main__': 17 | main() 18 | -------------------------------------------------------------------------------- /examples/django_app/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aldryncore/webservices/b18b8d8d386c905db2d8fcd196de027194033838/examples/django_app/api/__init__.py -------------------------------------------------------------------------------- /examples/django_app/api/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from api.models import Key 3 | 4 | admin.site.register(Key) 5 | -------------------------------------------------------------------------------- /examples/django_app/api/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Key(models.Model): 5 | public_key = models.CharField(max_length=100, unique=True) 6 | private_key = models.CharField(max_length=100, unique=True) 7 | 8 | def __unicode__(self): 9 | return 'Public Key: %s, Private Key: %s' % ( 10 | self.public_key, 11 | self.private_key, 12 | ) 13 | -------------------------------------------------------------------------------- /examples/django_app/api/tests.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file demonstrates writing tests using the unittest module. These will pass 3 | when you run "manage.py test". 4 | 5 | Replace this with more appropriate tests for your application. 6 | """ 7 | 8 | from django.test import TestCase 9 | 10 | 11 | class SimpleTest(TestCase): 12 | def test_basic_addition(self): 13 | """ 14 | Tests that 1 + 1 always equals 2. 15 | """ 16 | self.assertEqual(1 + 1, 2) 17 | -------------------------------------------------------------------------------- /examples/django_app/api/views.py: -------------------------------------------------------------------------------- 1 | from webservices.models import Provider 2 | 3 | from api.models import Key 4 | 5 | 6 | class HelloProvider(Provider): 7 | def get_private_key(self, public_key): 8 | try: 9 | return Key.objects.get(public_key=public_key).private_key 10 | except Key.DoesNotExist: 11 | return None 12 | 13 | def provide(self, data): 14 | name = data.get('name', 'world') 15 | return {'greeting': u'hello %s' % name} 16 | -------------------------------------------------------------------------------- /examples/django_app/django_app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aldryncore/webservices/b18b8d8d386c905db2d8fcd196de027194033838/examples/django_app/django_app/__init__.py -------------------------------------------------------------------------------- /examples/django_app/django_app/settings.py: -------------------------------------------------------------------------------- 1 | # Django settings for django_app project. 2 | 3 | TEMPLATE_DEBUG = DEBUG = True 4 | 5 | MANAGERS = ADMINS = () 6 | 7 | DATABASES = { 8 | 'default': { 9 | 'ENGINE': 'django.db.backends.sqlite3', 10 | 'NAME': 'keys.sqlite', 11 | } 12 | } 13 | 14 | TIME_ZONE = 'America/Chicago' 15 | 16 | LANGUAGE_CODE = 'en-us' 17 | 18 | SITE_ID = 1 19 | 20 | USE_I18N = True 21 | 22 | USE_L10N = True 23 | 24 | USE_TZ = True 25 | 26 | STATIC_URL = '/static/' 27 | 28 | STATICFILES_DIRS = () 29 | 30 | STATICFILES_FINDERS = ( 31 | 'django.contrib.staticfiles.finders.FileSystemFinder', 32 | 'django.contrib.staticfiles.finders.AppDirectoriesFinder', 33 | ) 34 | 35 | SECRET_KEY = 'j@)bmb^=#g4+$01mi^)jy)^bt!5zz+7%-^vqhc6d^39!eil804' 36 | 37 | TEMPLATE_LOADERS = ( 38 | 'django.template.loaders.filesystem.Loader', 39 | 'django.template.loaders.app_directories.Loader', 40 | ) 41 | 42 | MIDDLEWARE_CLASSES = ( 43 | 'django.middleware.common.CommonMiddleware', 44 | 'django.contrib.sessions.middleware.SessionMiddleware', 45 | 'django.middleware.csrf.CsrfViewMiddleware', 46 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 47 | 'django.contrib.messages.middleware.MessageMiddleware', 48 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 49 | ) 50 | 51 | ROOT_URLCONF = 'django_app.urls' 52 | 53 | WSGI_APPLICATION = 'django_app.wsgi.application' 54 | 55 | TEMPLATE_DIRS = () 56 | 57 | INSTALLED_APPS = ( 58 | 'django.contrib.auth', 59 | 'django.contrib.contenttypes', 60 | 'django.contrib.sessions', 61 | 'django.contrib.sites', 62 | 'django.contrib.messages', 63 | 'django.contrib.staticfiles', 64 | 'django.contrib.admin', 65 | 'api', 66 | ) 67 | 68 | LOGGING = { 69 | 'version': 1, 70 | 'disable_existing_loggers': False, 71 | 'filters': { 72 | 'require_debug_false': { 73 | '()': 'django.utils.log.RequireDebugFalse' 74 | } 75 | }, 76 | 'handlers': { 77 | 'mail_admins': { 78 | 'level': 'ERROR', 79 | 'filters': ['require_debug_false'], 80 | 'class': 'django.utils.log.AdminEmailHandler' 81 | } 82 | }, 83 | 'loggers': { 84 | 'django.request': { 85 | 'handlers': ['mail_admins'], 86 | 'level': 'ERROR', 87 | 'propagate': True, 88 | }, 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /examples/django_app/django_app/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import patterns, include, url 2 | from webservices.sync import provider_for_django 3 | from api.views import HelloProvider 4 | from django.contrib import admin 5 | 6 | admin.autodiscover() 7 | 8 | urlpatterns = patterns( 9 | '', 10 | url(r'^$', provider_for_django(HelloProvider())), 11 | url(r'^admin/', include(admin.site.urls)), 12 | ) 13 | -------------------------------------------------------------------------------- /examples/django_app/django_app/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for django_app project. 3 | 4 | This module contains the WSGI application used by Django's development server 5 | and any production WSGI deployments. It should expose a module-level variable 6 | named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover 7 | this application via the ``WSGI_APPLICATION`` setting. 8 | 9 | Usually you will have the standard Django WSGI application here, but it also 10 | might make sense to replace the whole Django WSGI application with a custom one 11 | that later delegates to the Django one. For example, you could introduce WSGI 12 | middleware here, or combine a Django application with an application of another 13 | framework. 14 | 15 | """ 16 | import os 17 | 18 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_app.settings") 19 | 20 | # This application object is used by any WSGI server configured to use this 21 | # file. This includes Django's development server, if the WSGI_APPLICATION 22 | # setting points here. 23 | from django.core.wsgi import get_wsgi_application 24 | application = get_wsgi_application() 25 | 26 | # Apply WSGI middleware here. 27 | # from helloworld.wsgi import HelloWorldApplication 28 | # application = HelloWorldApplication(application) 29 | -------------------------------------------------------------------------------- /examples/django_app/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_app.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /examples/flask_app/app.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | from webservices.models import Provider 3 | from webservices.sync import provider_for_flask 4 | import os 5 | 6 | app = Flask(__name__) 7 | 8 | 9 | with open(os.path.join(os.path.dirname(__file__), 'keys.txt')) as fobj: 10 | data = fobj.read() 11 | app.keys = dict([ 12 | line.split(':') 13 | for line in data.split('\n') 14 | if line.strip() 15 | ]) 16 | 17 | 18 | class HelloProvider(Provider): 19 | def get_private_key(self, public_key): 20 | private_key = app.keys.get(public_key) 21 | return private_key 22 | 23 | def provide(self, data): 24 | name = data.get('name', 'world') 25 | return {'greeting': u'hello %s' % name} 26 | 27 | 28 | provider_for_flask(app, '/', HelloProvider()) 29 | 30 | if __name__ == '__main__': 31 | app.run(port=8000, debug=True) 32 | -------------------------------------------------------------------------------- /examples/flask_app/keys.txt: -------------------------------------------------------------------------------- 1 | pubkey:privkey -------------------------------------------------------------------------------- /examples/twisted_app/app.py: -------------------------------------------------------------------------------- 1 | from twisted.internet import reactor 2 | from twisted.web.server import Site 3 | 4 | from webservices.async import provider_for_twisted 5 | from webservices.models import Provider 6 | 7 | 8 | API_KEYS = { 9 | 'pubkey': 'privkey', # your keys here 10 | } 11 | 12 | 13 | class HelloProvider(Provider): 14 | def get_private_key(self, public_key): 15 | return API_KEYS.get(public_key) 16 | 17 | def provide(self, data): 18 | name = data.get('name', 'world') 19 | return {'greeting': u'hello %s' % name} 20 | 21 | 22 | resource = provider_for_twisted(HelloProvider()) 23 | 24 | site = Site(resource) 25 | reactor.listenTCP(8000, site) 26 | reactor.run() 27 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | import webservices 3 | 4 | 5 | setup( 6 | author="Jonas Obrist", 7 | name='webservices', 8 | version=webservices.__version__, 9 | license='BSD License', 10 | platforms=['OS Independent'], 11 | install_requires=[ 12 | 'itsdangerous', 13 | ], 14 | extras_require={ 15 | 'django': ["django", "requests"], 16 | 'flask': ["flask", "requests"], 17 | 'twisted': ["twisted"], 18 | 'consumer': ["requests"], 19 | }, 20 | tests_require=[ 21 | 'twisted', 22 | 'requests', 23 | 'django', 24 | 'flask', 25 | ], 26 | packages=find_packages(), 27 | include_package_data=False, 28 | zip_safe=False, 29 | test_suite='webservices.tests', 30 | ) 31 | -------------------------------------------------------------------------------- /webservices/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.7' 2 | -------------------------------------------------------------------------------- /webservices/async.py: -------------------------------------------------------------------------------- 1 | from twisted.internet import threads 2 | from twisted.web import server 3 | from twisted.web.client import getPage 4 | from twisted.web.resource import Resource 5 | 6 | from webservices.exceptions import BadRequest, WebserviceError 7 | from webservices.models import BaseConsumer 8 | 9 | 10 | class TwistedConsumer(BaseConsumer): 11 | def send_request(self, url, data, headers): 12 | return getPage(url, method='POST', postdata=data, headers=headers) 13 | 14 | def handle_response(self, response, max_age): 15 | def callback(body): 16 | return self.signer.loads(body, max_age=max_age) 17 | 18 | def errback(fail): 19 | message = fail.value.message 20 | status_code = fail.value.status 21 | self.raise_for_status(int(status_code), message) 22 | response.addCallback(callback) 23 | response.addErrback(errback) 24 | return response 25 | 26 | def raise_for_status(self, status_code, message): 27 | if status_code == 400: 28 | raise BadRequest(message) 29 | elif status_code >= 300: 30 | raise WebserviceError(message) 31 | 32 | 33 | class ProviderResource(Resource): 34 | isLeaf = True 35 | 36 | def __init__(self, provider): 37 | self.provider = provider 38 | Resource.__init__(self) 39 | 40 | def render_POST(self, request): 41 | def get_header(key, default): 42 | return request.getHeader(key) or default 43 | 44 | def callback(info): 45 | status_code, data = info 46 | request.setResponseCode(status_code) 47 | request.write(data) 48 | request.finish() 49 | 50 | # XXX: This is not the correct way to pass the data to the processing 51 | # function. The content is read from disk and could be too large 52 | # to fit into memory. The correct way to go is to pass an open 53 | # file handler as argument instead of a single string, but this 54 | # would require each provider to be adapted. 55 | request.content.seek(0) 56 | signed_data = request.content.read() 57 | 58 | deferred = threads.deferToThread( 59 | self.provider.get_response, 60 | 'POST', 61 | signed_data, 62 | get_header, 63 | ) 64 | deferred.addCallback(callback) 65 | return server.NOT_DONE_YET 66 | 67 | 68 | def provider_for_twisted(provider): 69 | return ProviderResource(provider) 70 | -------------------------------------------------------------------------------- /webservices/exceptions.py: -------------------------------------------------------------------------------- 1 | class WebserviceError(Exception): 2 | pass 3 | 4 | 5 | class BadRequest(WebserviceError): 6 | pass 7 | -------------------------------------------------------------------------------- /webservices/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | try: 3 | # python 3 4 | # noinspection PyCompatibility 5 | from urllib.parse import urlparse, urlunparse, urljoin 6 | except ImportError: 7 | # python 2 8 | # noinspection PyCompatibility 9 | from urlparse import urlparse, urlunparse, urljoin 10 | 11 | from itsdangerous import TimedSerializer, SignatureExpired, BadSignature 12 | 13 | from webservices.exceptions import BadRequest, WebserviceError 14 | 15 | 16 | PUBLIC_KEY_HEADER = 'x-services-public-key' 17 | 18 | 19 | def _split_dsn(dsn): 20 | parse_result = urlparse(dsn) 21 | host = parse_result.hostname 22 | if parse_result.port: 23 | host += ':%s' % parse_result.port 24 | base_url = urlunparse(( 25 | parse_result.scheme, 26 | host, 27 | parse_result.path, 28 | parse_result.params, 29 | parse_result.query, 30 | parse_result.fragment, 31 | )) 32 | return base_url, parse_result.username, parse_result.password 33 | 34 | 35 | class BaseConsumer(object): 36 | def __init__(self, base_url, public_key, private_key): 37 | self.base_url = base_url 38 | self.public_key = public_key 39 | self.signer = TimedSerializer(private_key) 40 | 41 | @classmethod 42 | def from_dsn(cls, dsn): 43 | base_url, public_key, private_key = _split_dsn(dsn) 44 | return cls(base_url, public_key, private_key) 45 | 46 | def consume(self, path, data, max_age=None): 47 | if not path.startswith('/'): 48 | raise ValueError("Paths must start with a slash") 49 | signed_data = self.signer.dumps(data) 50 | headers = { 51 | PUBLIC_KEY_HEADER: self.public_key, 52 | 'Content-Type': 'application/json', 53 | } 54 | url = self.build_url(path) 55 | body = self.send_request(url, data=signed_data, headers=headers) 56 | return self.handle_response(body, max_age) 57 | 58 | def handle_response(self, body, max_age): 59 | return self.signer.loads(body, max_age=max_age) 60 | 61 | def send_request(self, url, data, headers): 62 | raise NotImplementedError( 63 | 'Implement send_request on BaseConsumer subclasses') 64 | 65 | def raise_for_status(self, status_code, message): 66 | if status_code == 400: 67 | raise BadRequest(message) 68 | elif status_code >= 300: 69 | raise WebserviceError(message) 70 | 71 | def build_url(self, path): 72 | path = path.lstrip('/') 73 | return urljoin(self.base_url, path) 74 | 75 | 76 | class Provider(object): 77 | max_age = None 78 | 79 | def provide(self, data): 80 | raise NotImplementedError( 81 | 'Subclasses of services.models.Provider must implement ' 82 | 'the provide method' 83 | ) 84 | 85 | def get_private_key(self, public_key): 86 | raise NotImplementedError( 87 | 'Subclasses of services.models.Provider must implement ' 88 | 'the get_private_key method' 89 | ) 90 | 91 | def report_exception(self): 92 | pass 93 | 94 | def get_response(self, method, signed_data, get_header): 95 | if method != 'POST': 96 | return (405, ['POST']) 97 | public_key = get_header(PUBLIC_KEY_HEADER, None) 98 | if not public_key: 99 | return (400, "No public key") 100 | private_key = self.get_private_key(public_key) 101 | if not private_key: 102 | return (400, "Invalid public key") 103 | signer = TimedSerializer(private_key) 104 | try: 105 | data = signer.loads(signed_data, max_age=self.max_age) 106 | except SignatureExpired: 107 | return (400, "Signature expired") 108 | except BadSignature: 109 | return (400, "Bad Signature") 110 | try: 111 | raw_response_data = self.provide(data) 112 | except: 113 | self.report_exception() 114 | return (400, "Failed to process the request") 115 | response_data = signer.dumps(raw_response_data) 116 | return (200, response_data) 117 | -------------------------------------------------------------------------------- /webservices/sync.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from webservices.models import BaseConsumer 4 | 5 | 6 | class SyncConsumer(BaseConsumer): 7 | def __init__(self, base_url, public_key, private_key): 8 | super(SyncConsumer, self).__init__(base_url, public_key, private_key) 9 | self.session = requests.session() 10 | 11 | def send_request(self, url, data, headers): # pragma: no cover 12 | response = self.session.post(url, data=data, headers=headers) 13 | self.raise_for_status(response.status_code, response.content) 14 | return response.content 15 | 16 | 17 | class DjangoTestingConsumer(SyncConsumer): 18 | def __init__(self, test_client, base_url, public_key, private_key): 19 | self.test_client = test_client 20 | super(DjangoTestingConsumer, self).__init__( 21 | base_url, public_key, private_key) 22 | 23 | def build_url(self, path): 24 | return path 25 | 26 | def send_request(self, url, data, headers): 27 | headers = { 28 | 'HTTP_%s' % header.upper().replace('-', '_'): value 29 | for header, value in headers.items() 30 | } 31 | response = self.test_client.post( 32 | url, 33 | data=data, 34 | content_type='application/json', 35 | **headers 36 | ) 37 | self.raise_for_status(response.status_code, response.content) 38 | return response.content 39 | 40 | 41 | class FlaskTestingConsumer(DjangoTestingConsumer): 42 | def send_request(self, url, data, headers): 43 | response = self.test_client.post(url, data=data, headers=headers) 44 | self.raise_for_status(response.status_code, response.data) 45 | return response.data 46 | 47 | 48 | def provider_for_django(provider): 49 | from django.http import HttpResponse 50 | from django.views.decorators.csrf import csrf_exempt 51 | 52 | def provider_view(request): 53 | def get_header(key, default): 54 | django_key = 'HTTP_%s' % key.upper().replace('-', '_') 55 | return request.META.get(django_key, default) 56 | method = request.method 57 | if getattr(request, 'body', None): 58 | signed_data = request.body 59 | else: 60 | signed_data = request.raw_post_data 61 | status_code, data = provider.get_response( 62 | method, 63 | signed_data, 64 | get_header, 65 | ) 66 | return HttpResponse(data, status=status_code) 67 | return csrf_exempt(provider_view) 68 | 69 | 70 | def provider_for_flask(app, url, provider): 71 | from flask import request 72 | 73 | def provider_view(): 74 | def get_header(key, default): 75 | return request.headers.get(key, default) 76 | method = request.method 77 | signed_data = request.data 78 | status_code, data = provider.get_response( 79 | method, 80 | signed_data, 81 | get_header, 82 | ) 83 | return data, status_code 84 | return app.route(url, methods=['POST'])(provider_view) 85 | -------------------------------------------------------------------------------- /webservices/tests.py: -------------------------------------------------------------------------------- 1 | # must be first: 2 | from django.conf import settings 3 | settings.configure( 4 | ROOT_URLCONF='webservices.tests', 5 | ALLOWED_HOSTS='*', 6 | DATABASES={ 7 | 'default': { 8 | 'ENGINE': 'django.db.backends.sqlite3', 9 | 'NAME': ':memory:' 10 | }, 11 | }, 12 | ) 13 | 14 | # real import 15 | import sys 16 | from unittest import TestCase 17 | 18 | from django.test.testcases import TestCase as DjangoTestCase 19 | 20 | from twisted.internet import reactor 21 | from twisted.trial.unittest import TestCase as TwistedTestCase 22 | from twisted.web.server import Site 23 | 24 | from webservices.async import provider_for_twisted, TwistedConsumer 25 | from webservices.exceptions import BadRequest, WebserviceError 26 | from webservices.models import Provider, BaseConsumer, _split_dsn 27 | from webservices.sync import ( 28 | provider_for_flask, 29 | FlaskTestingConsumer, 30 | provider_for_django, 31 | DjangoTestingConsumer, 32 | ) 33 | 34 | 35 | urlpatterns = [] 36 | 37 | 38 | class GreetingProvider(Provider): 39 | keys = { 40 | 'pubkey': 'privatekey' 41 | } 42 | 43 | def __init__(self): 44 | self.exceptions = [] 45 | 46 | def get_private_key(self, key): 47 | return self.keys.get(key) 48 | 49 | def report_exception(self): 50 | self.exceptions.append(sys.exc_info()) 51 | 52 | def provide(self, data): 53 | if data.get('error'): 54 | raise Exception('Error') 55 | name = data.get('name', 'World') 56 | return {'greeting': u'Hello %s!' % name} 57 | 58 | 59 | class GetFlaskTestingConsumer(FlaskTestingConsumer): 60 | def send_request(self, url, data, headers): # pragma: no cover 61 | response = self.test_client.get(url, data=data, headers=headers) 62 | self.raise_for_status(response.status_code, response.data) 63 | return response.data 64 | 65 | 66 | class BaseTests(TestCase): 67 | def test_consume_base_consumer(self): 68 | consumer = BaseConsumer('http://localhost', 'pubkey', 'privatekey') 69 | self.assertRaises(NotImplementedError, 70 | consumer.consume, '/', {'name': 'Test'}) 71 | 72 | def test_split_dsn(self): 73 | dsn = 'https://pub:priv@hostname.tld:1234/path' 74 | base_url, public_key, private_key = _split_dsn(dsn) 75 | self.assertEqual(base_url, 'https://hostname.tld:1234/path') 76 | self.assertEqual(public_key, 'pub') 77 | self.assertEqual(private_key, 'priv') 78 | 79 | 80 | class FlaskTests(TestCase): 81 | def setUp(self): 82 | from flask import Flask 83 | app = Flask(__name__) 84 | app.config['TESTING'] = True 85 | self.provider = GreetingProvider() 86 | provider_for_flask(app, '/', self.provider) 87 | self.client = app.test_client() 88 | 89 | def test_greeting_provider(self): 90 | consumer = FlaskTestingConsumer( 91 | self.client, 'http://localhost', 'pubkey', 'privatekey') 92 | output = consumer.consume('/', {'name': 'Test'}) 93 | self.assertEqual(output['greeting'], 'Hello Test!') 94 | 95 | def test_greeting_provider_wrong_key(self): 96 | consumer = FlaskTestingConsumer( 97 | self.client, 'http://localhost', 'pubkey', 'wrongkey') 98 | self.assertRaises(BadRequest, consumer.consume, '/', {'name': 'Test'}) 99 | 100 | def test_starts_with_slash(self): 101 | consumer = FlaskTestingConsumer( 102 | self.client, 'http://localhost', 'pubkey', 'wrongkey') 103 | self.assertRaises(ValueError, 104 | consumer.consume, 'wrong', {'name': 'Test'}) 105 | 106 | def test_method_not_allowed(self): 107 | consumer = GetFlaskTestingConsumer( 108 | self.client, 'http://localhost', 'pubkey', 'wrongkey') 109 | self.assertRaises(WebserviceError, 110 | consumer.consume, '/', {'name': 'Test'}) 111 | 112 | def test_exception_hook(self): 113 | consumer = FlaskTestingConsumer( 114 | self.client, 'http://localhost', 'pubkey', 'privatekey') 115 | self.assertRaises(BadRequest, consumer.consume, '/', {'error': True}) 116 | self.assertEqual(len(self.provider.exceptions), 1) 117 | 118 | 119 | class DjangoTests(DjangoTestCase): 120 | def setUp(self): 121 | from django.test.client import Client 122 | self.client = Client() 123 | 124 | @property 125 | def urls(self): 126 | from django.conf.urls import url, patterns 127 | return patterns( 128 | '', 129 | url(r'^$', provider_for_django(GreetingProvider())), 130 | ) 131 | 132 | def test_greeting_provider(self): 133 | consumer = DjangoTestingConsumer( 134 | self.client, 'http://localhost', 'pubkey', 'privatekey') 135 | output = consumer.consume('/', {'name': 'Test'}) 136 | self.assertEqual(output['greeting'], 'Hello Test!') 137 | 138 | def test_greeting_provider_wrong_key(self): 139 | consumer = DjangoTestingConsumer( 140 | self.client, 'http://localhost', 'pubkey', 'wrongkey') 141 | self.assertRaises(BadRequest, consumer.consume, '/', {'name': 'Test'}) 142 | 143 | 144 | class TwistedTests(TwistedTestCase): 145 | def setUp(self): 146 | resource = provider_for_twisted(GreetingProvider()) 147 | factory = Site(resource) 148 | self.port = reactor.listenTCP(0, factory, interface="127.0.0.1") 149 | self.client = None 150 | 151 | def tearDown(self): 152 | if self.client is not None: 153 | self.client.transport.loseConnection() 154 | return self.port.stopListening() 155 | 156 | def _test(self, public_key, private_key, path, data): 157 | base_url = 'http://127.0.0.1:%s/' % self.port.getHost().port 158 | consumer = TwistedConsumer(base_url, public_key, private_key) 159 | return consumer.consume(path, data) 160 | 161 | def test_greeting_provider(self): 162 | def cb(result): 163 | self.assertEqual(result['greeting'], 'Hello Test!') 164 | d = self._test('pubkey', 'privatekey', '/', {'name': 'Test'}) 165 | d.addCallback(cb) 166 | return d 167 | 168 | def test_greeting_provider_wrong_key(self): 169 | def cb(result): 170 | self.assertRaises(BadRequest, result.raiseException) 171 | d = self._test('pubkey', 'wrongkey', '/', {'name': 'Test'}) 172 | d.addErrback(cb) 173 | return d 174 | --------------------------------------------------------------------------------