diff --git a/invenio/modules/webhooks/__init__.py b/invenio/modules/webhooks/__init__.py new file mode 100644 index 000000000..0eab0f888 --- /dev/null +++ b/invenio/modules/webhooks/__init__.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +## +## This file is part of Invenio. +## Copyright (C) 2014 CERN. +## +## Invenio is free software; you can redistribute it and/or +## modify it under the terms of the GNU General Public License as +## published by the Free Software Foundation; either version 2 of the +## License, or (at your option) any later version. +## +## Invenio is distributed in the hope that it will be useful, but +## WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +## General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with Invenio; if not, write to the Free Software Foundation, Inc., +## 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA. diff --git a/invenio/modules/webhooks/config.py b/invenio/modules/webhooks/config.py new file mode 100644 index 000000000..7d474f3b8 --- /dev/null +++ b/invenio/modules/webhooks/config.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +## +## This file is part of Invenio. +## Copyright (C) 2014 CERN. +## +## Invenio is free software; you can redistribute it and/or +## modify it under the terms of the GNU General Public License as +## published by the Free Software Foundation; either version 2 of the +## License, or (at your option) any later version. +## +## Invenio is distributed in the hope that it will be useful, but +## WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +## General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with Invenio; if not, write to the Free Software Foundation, Inc., +## 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA. + +WEBHOOKS_DEBUG_RECEIVER_URLS = {} +""" +Mapping of receiver id to URL pattern. This allows generating URLs to an +intermediate webhook proxy service like Ultrahook for testing on development +machines:: + + WEBHOOKS_DEBUG_RECEIVER_URLS = { + 'github': 'https://hook.user.ultrahook.com/?access_token=%%(token)s' + } +""" diff --git a/invenio/modules/webhooks/models.py b/invenio/modules/webhooks/models.py new file mode 100644 index 000000000..6351a4275 --- /dev/null +++ b/invenio/modules/webhooks/models.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +## +## This file is part of Invenio. +## Copyright (C) 2014 CERN. +## +## Invenio is free software; you can redistribute it and/or +## modify it under the terms of the GNU General Public License as +## published by the Free Software Foundation; either version 2 of the +## License, or (at your option) any later version. +## +## Invenio is distributed in the hope that it will be useful, but +## WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +## General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with Invenio; if not, write to the Free Software Foundation, Inc., +## 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA. + +from __future__ import absolute_import + +from flask import request, url_for, current_app + +from .registry import receivers_registry + + +# +# Errors +# +class WebhookError(Exception): + """ General WebHookError """ + pass + + +class ReceiverDoesNotExists(WebhookError): + pass + + +class InvalidPayload(WebhookError): + pass + + +# +# Models +# +class Receiver(object): + """ + Base class for all receivers. A receiver is responsible for receiving and + extracting a payload from a request, and passing it on to a method which + can handle the event notification. + """ + def __init__(self, fn): + self._callable = fn + + @classmethod + def get(cls, receiver_id): + try: + return receivers_registry[receiver_id] + except KeyError: + raise ReceiverDoesNotExists(receiver_id) + + @classmethod + def all(cls): + return receivers_registry + + @classmethod + def register(cls, receiver_id, receiver): + receivers_registry[receiver_id] = receiver + + @classmethod + def unregister(cls, receiver_id): + del receivers_registry[receiver_id] + + @classmethod + def get_hook_url(cls, receiver_id, access_token): + cls.get(receiver_id) + # Allow overwriting hook URL in debug mode. + if current_app.debug and \ + current_app.config.get('WEBHOOKS_DEBUG_RECEIVER_URLS', None): + url_pattern = current_app.config[ + 'WEBHOOKS_DEBUG_RECEIVER_URLS'].get(receiver_id, None) + if url_pattern: + return url_pattern % dict(token=access_token) + return url_for( + 'receivereventlistresource', + receiver_id='github', + access_token=access_token, + _external=True + ) + + # + # Instance methods (override if needed) + # + def consume_event(self, user_id): + """ + Consume a webhook event by calling the associated callable + """ + event = self._create_event(user_id) + self._callable(event) + + def _create_event(self, user_id): + return Event( + user_id, + payload=self.extract_payload() + ) + + def extract_payload(self): + """ + Method to extract payload from request. + """ + if request.content_type == 'application/json': + return request.get_json() + elif request.content_type == 'application/x-www-form-urlencoded': + return dict(request.form) + raise InvalidPayload(request.content_type) + + +class CeleryReceiver(Receiver): + """ + Receiver which will fire a celery task to handle payload instead of running + it synchronously during the request. + """ + def __init__(self, task_callable, **options): + self._task = task_callable + self._options = options + from celery import Task + assert isinstance(self._task, Task) + + def consume_event(self, user_id): + event = self._create_event(user_id) + self._task.apply_async(args=[event.__getstate__()], **self._options) + + +class Event(object): + """ + Represents webhook event data which consists of a payload and a user id. + """ + def __init__(self, user_id=None, payload=None): + self.user_id = user_id + self.payload = payload + + def __getstate__(self): + return dict( + user_id=self.user_id, + payload=self.payload, + ) + + def __setstate__(self, state): + self.user_id = state['user_id'] + self.payload = state['payload'] diff --git a/invenio/modules/webhooks/registry.py b/invenio/modules/webhooks/registry.py new file mode 100644 index 000000000..208f05602 --- /dev/null +++ b/invenio/modules/webhooks/registry.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +## +## This file is part of Invenio. +## Copyright (C) 2014 CERN. +## +## Invenio is free software; you can redistribute it and/or +## modify it under the terms of the GNU General Public License as +## published by the Free Software Foundation; either version 2 of the +## License, or (at your option) any later version. +## +## Invenio is distributed in the hope that it will be useful, but +## WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +## General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with Invenio; if not, write to the Free Software Foundation, Inc., +## 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA. + +from flask.ext.registry import DictRegistry, RegistryProxy + +receivers_registry = RegistryProxy('webhookext', DictRegistry) diff --git a/invenio/modules/webhooks/restful.py b/invenio/modules/webhooks/restful.py new file mode 100644 index 000000000..bf1991491 --- /dev/null +++ b/invenio/modules/webhooks/restful.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +## +## This file is part of Invenio. +## Copyright (C) 2014 CERN. +## +## Invenio is free software; you can redistribute it and/or +## modify it under the terms of the GNU General Public License as +## published by the Free Software Foundation; either version 2 of the +## License, or (at your option) any later version. +## +## Invenio is distributed in the hope that it will be useful, but +## WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +## General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with Invenio; if not, write to the Free Software Foundation, Inc., +## 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA. + +from __future__ import absolute_import + +from functools import wraps + +from flask.ext.restful import Resource, abort +from invenio.ext.restful import require_api_auth, require_oauth_scopes +from .models import Receiver, ReceiverDoesNotExists, InvalidPayload, \ + WebhookError + + +def error_handler(f): + """ + Decorator to handle exceptions + """ + @wraps(f) + def inner(*args, **kwargs): + try: + return f(*args, **kwargs) + except ReceiverDoesNotExists: + abort(404, message="Receiver does not exists.", status=404) + except InvalidPayload as e: + abort( + 415, + message="Receiver does not support the" + " content-type '%s'." % e.args[0], + status=415) + except WebhookError as e: + abort( + 500, + message="Internal server error", + status=500 + ) + return inner + +# +# Default decorators +# +api_decorators = [ + require_api_auth(), + error_handler, +] + + +# +# REST Resources +# +class ReceiverEventListResource(Resource): + """ + Receiver event hook + """ + method_decorators = api_decorators + + def get(self, oauth, receiver_id=None): + abort(405) + + @require_oauth_scopes('webhooks:event') + def post(self, oauth, receiver_id=None): + receiver = Receiver.get(receiver_id) + receiver.consume_event(oauth.access_token.user_id) + return {'status': 202, 'message': 'Accepted'}, 202 + + def put(self, oauth, receiver_id=None): + abort(405) + + def delete(self, oauth, receiver_id=None): + abort(405) + + def head(self, oauth, receiver_id=None): + abort(405) + + def options(self, oauth, receiver_id=None): + abort(405) + + def patch(self, oauth, receiver_id=None): + abort(405) + + +# +# Register API resources +# +def setup_app(app, api): + api.add_resource( + ReceiverEventListResource, + '/api/hooks/receivers/<string:receiver_id>/events/', + ) diff --git a/invenio/modules/webhooks/testsuite/__init__.py b/invenio/modules/webhooks/testsuite/__init__.py new file mode 100644 index 000000000..0eab0f888 --- /dev/null +++ b/invenio/modules/webhooks/testsuite/__init__.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +## +## This file is part of Invenio. +## Copyright (C) 2014 CERN. +## +## Invenio is free software; you can redistribute it and/or +## modify it under the terms of the GNU General Public License as +## published by the Free Software Foundation; either version 2 of the +## License, or (at your option) any later version. +## +## Invenio is distributed in the hope that it will be useful, but +## WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +## General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with Invenio; if not, write to the Free Software Foundation, Inc., +## 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA. diff --git a/invenio/modules/webhooks/testsuite/test_api.py b/invenio/modules/webhooks/testsuite/test_api.py new file mode 100644 index 000000000..f4495e7b1 --- /dev/null +++ b/invenio/modules/webhooks/testsuite/test_api.py @@ -0,0 +1,166 @@ +# -*- coding: utf-8 -*- +## +## This file is part of Invenio. +## Copyright (C) 2014 CERN. +## +## Invenio is free software; you can redistribute it and/or +## modify it under the terms of the GNU General Public License as +## published by the Free Software Foundation; either version 2 of the +## License, or (at your option) any later version. +## +## Invenio is distributed in the hope that it will be useful, but +## WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +## General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with Invenio; if not, write to the Free Software Foundation, Inc., +## 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA. + +from __future__ import absolute_import + +from invenio.ext.sqlalchemy import db +from invenio.ext.restful.utils import APITestCase + +from ..models import Receiver + + +class WebHooksTestCase(APITestCase): + def setUp(self): + from invenio.modules.accounts.models import User + self.user = User( + email='info@invenio-software.org', nickname='tester' + ) + self.user.password = "tester" + db.session.add(self.user) + db.session.commit() + self.create_oauth_token(self.user.id, scopes=["webhooks:event"]) + + self.called = 0 + self.payload = None + self.user_id = None + + def tearDown(self): + self.remove_oauth_token() + if self.user: + db.session.delete(self.user) + db.session.commit() + + self.called = None + self.payload = None + self.user_id = None + + def callable(self, event): + self.called += 1 + self.payload = event.payload + self.user_id = event.user_id + + def callable_wrong_signature(self): + self.called += 1 + + def test_405_methods(self): + methods = [ + self.get, self.put, self.delete, self.head, self.options, + self.patch + ] + + for m in methods: + m( + 'receivereventlistresource', + urlargs=dict(receiver_id='test-receiver'), + code=405, + ) + + def test_webhook_post(self): + self.post( + 'receivereventlistresource', + urlargs=dict(receiver_id='test-receiver'), + code=404, + ) + + r = Receiver(self.callable) + r_invalid = Receiver(self.callable_wrong_signature) + + Receiver.register('test-receiver', r) + Receiver.register('test-broken-receiver', r_invalid) + + payload = dict(somekey='somevalue') + self.post( + 'receivereventlistresource', + urlargs=dict(receiver_id='test-receiver'), + data=payload, + code=202, + ) + + assert self.called == 1 + assert self.user_id == self.user.id + assert self.payload == payload + + # Test invalid payload + import pickle + payload = dict(somekey='somevalue') + self.post( + 'receivereventlistresource', + urlargs=dict(receiver_id='test-receiver'), + data=pickle.dumps(payload), + is_json=False, + headers=[('Content-Type', 'application/python-pickle')], + code=415, + ) + + # Test invalid payload, with wrong content-type + import pickle + self.post( + 'receivereventlistresource', + urlargs=dict(receiver_id='test-receiver'), + data=pickle.dumps(payload), + is_json=False, + headers=[('Content-Type', 'application/json')], + code=400, + ) + + +class WebHooksScopesTestCase(APITestCase): + def setUp(self): + from invenio.modules.accounts.models import User + self.user = User( + email='info@invenio-software.org', nickname='tester' + ) + self.user.password = "tester" + db.session.add(self.user) + db.session.commit() + self.create_oauth_token(self.user.id, scopes=[""]) + + def tearDown(self): + self.remove_oauth_token() + if self.user: + db.session.delete(self.user) + db.session.commit() + + def callable(self, event): + pass + + def test_405_methods_no_scope(self): + methods = [ + self.get, self.put, self.delete, self.head, self.options, + self.patch + ] + + for m in methods: + m( + 'receivereventlistresource', + urlargs=dict(receiver_id='test-receiver'), + code=405, + ) + + def test_webhook_post(self): + r = Receiver(self.callable) + Receiver.register('test-receiver-no-scope', r) + + payload = dict(somekey='somevalue') + self.post( + 'receivereventlistresource', + urlargs=dict(receiver_id='test-receiver-no-scope'), + data=payload, + code=403, + ) diff --git a/invenio/modules/webhooks/testsuite/test_receivers.py b/invenio/modules/webhooks/testsuite/test_receivers.py new file mode 100644 index 000000000..a2bb55ccc --- /dev/null +++ b/invenio/modules/webhooks/testsuite/test_receivers.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +## +## This file is part of Invenio. +## Copyright (C) 2014 CERN. +## +## Invenio is free software; you can redistribute it and/or +## modify it under the terms of the GNU General Public License as +## published by the Free Software Foundation; either version 2 of the +## License, or (at your option) any later version. +## +## Invenio is distributed in the hope that it will be useful, but +## WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +## General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with Invenio; if not, write to the Free Software Foundation, Inc., +## 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA. + +from __future__ import absolute_import + +import json +from flask_registry import RegistryError +from invenio.testsuite import InvenioTestCase + + +from ..models import Event, Receiver, InvalidPayload, CeleryReceiver, \ + ReceiverDoesNotExists + +from invenio.celery import celery + + +class ReceiverTestCase(InvenioTestCase): + def setUp(self): + self.called = 0 + self.payload = None + self.user_id = None + # Force synchronously task running + celery.conf['CELERY_ALWAYS_EAGER'] = True + + @celery.task(ignore_result=True) + def test_task(event_state): + e = Event() + e.__setstate__(event_state) + self.called += 1 + self.payload = e.payload + self.user_id = e.user_id + + self.task_callable = test_task + + def tearDown(self): + self.called = None + self.payload = None + self.user_id = None + + def callable(self, event): + self.called += 1 + self.payload = event.payload + self.user_id = event.user_id + + def callable_wrong_signature(self): + self.called += 1 + + def test_receiver_registration(self): + r = Receiver(self.callable) + r_invalid = Receiver(self.callable_wrong_signature) + + Receiver.register('test-receiver', r) + Receiver.register('test-invalid', r_invalid) + + assert 'test-receiver' in Receiver.all() + assert Receiver.get('test-receiver') == r + + # Double registration + self.assertRaises(RegistryError, Receiver.register, 'test-receiver', r) + + Receiver.unregister('test-receiver') + assert 'test-receiver' not in Receiver.all() + + Receiver.register('test-receiver', r) + + # JSON payload parsing + payload = json.dumps(dict(somekey='somevalue')) + headers = [('Content-Type', 'application/json')] + with self.app.test_request_context(headers=headers, data=payload): + r.consume_event(2) + assert self.called == 1 + assert self.payload == json.loads(payload) + assert self.user_id == 2 + + self.assertRaises(TypeError, r_invalid.consume_event, 2) + assert self.called == 1 + + # Form encoded values payload parsing + payload = dict(somekey='somevalue') + with self.app.test_request_context(method='POST', data=payload): + r.consume_event(2) + assert self.called == 2 + assert self.payload == dict(somekey=['somevalue']) + + # Test invalid post data + with self.app.test_request_context(method='POST', data="invaliddata"): + self.assertRaises(InvalidPayload, r.consume_event, 2) + + # Test Celery Receiver + rcelery = CeleryReceiver(self.task_callable) + CeleryReceiver.register('celery-receiver', rcelery) + + # Form encoded values payload parsing + payload = dict(somekey='somevalue') + with self.app.test_request_context(method='POST', data=payload): + rcelery.consume_event(1) + + assert self.called == 3 + assert self.payload == dict(somekey=['somevalue']) + assert self.user_id == 1 + + def test_unknown_receiver(self): + self.assertRaises(ReceiverDoesNotExists, Receiver.get, 'unknown') + + def test_hookurl(self): + r = Receiver(self.callable) + Receiver.register('test-receiver', r) + + assert Receiver.get_hook_url('test-receiver', 'token') != \ + 'http://test.local/?access_token=token' + + self.app.config['WEBHOOKS_DEBUG_RECEIVER_URLS'] = { + 'test-receiver': 'http://test.local/?access_token=%(token)s' + } + + assert Receiver.get_hook_url('test-receiver', 'token') == \ + 'http://test.local/?access_token=token' diff --git a/invenio/modules/webhooks/upgrades/__init__.py b/invenio/modules/webhooks/upgrades/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/invenio/modules/webhooks/views.py b/invenio/modules/webhooks/views.py new file mode 100644 index 000000000..a63bc26c8 --- /dev/null +++ b/invenio/modules/webhooks/views.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +## +## This file is part of Invenio. +## Copyright (C) 2014 CERN. +## +## Invenio is free software; you can redistribute it and/or +## modify it under the terms of the GNU General Public License as +## published by the Free Software Foundation; either version 2 of the +## License, or (at your option) any later version. +## +## Invenio is distributed in the hope that it will be useful, but +## WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +## General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with Invenio; if not, write to the Free Software Foundation, Inc., +## 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA. + +from flask import Blueprint + +blueprint = Blueprint( + 'webhooks', + __name__, +) + + +@blueprint.before_app_first_request +def register_scopes(): + """ + Register OAuth2 scopes for webhooks module + """ + from invenio.modules.oauth2server.registry import scopes + scopes.register('webhooks:read', dict(is_public=False, desc='')) + scopes.register('webhooks:write', dict(is_public=False, desc='')) + scopes.register('webhooks:event', dict(is_public=False, desc=''))