├── .gitignore ├── AUTHORS.md ├── LICENSE.md ├── README.md ├── devrequirements.txt ├── rest_hooks ├── __init__.py ├── admin.py ├── client.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── signals.py ├── tasks.py ├── tests.py └── utils.py ├── runtests.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg-info 3 | MANIFEST 4 | build 5 | dist 6 | docs/_build 7 | 8 | *.sqlite 9 | .sass-cache 10 | 11 | tests/reports -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | Django REST Hooks is written and maintained by Zapier and 2 | various contributors: 3 | 4 | 5 | ## Development Lead 6 | 7 | - Bryan Helmig 8 | 9 | 10 | ## Patches and Suggestions 11 | 12 | - You? -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Zapier LLC. 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a fork of https://github.com/zapier/django-rest-hooks which adds support for global hooks and more up to date django support. 2 | 3 | ## What are Django REST Hooks? 4 | 5 | REST Hooks are fancier versions of webhooks. Traditional webhooks are usually 6 | managed manually by the user, but REST Hooks are not! They encourage RESTful 7 | access to the hooks (or subscriptions) themselves. Add one, two or 15 hooks for 8 | any combination of event and URLs, then get notificatied in real-time by our 9 | bundled threaded callback mechanism. 10 | 11 | The best part is: by reusing Django's great signals framework, this library is 12 | dead simple. Here's how to get started: 13 | 14 | 1. Add `'rest_hooks'` to installed apps in settings.py. 15 | 2. Define your `HOOK_EVENTS` in settings.py. 16 | 3. Start sending hooks! 17 | 18 | Using our **built-in actions**, zero work is required to support *any* basic `created`, 19 | `updated`, and `deleted` actions across any Django model. We also allow for 20 | **custom actions** (IE: beyond **C**R**UD**) to be simply defined and triggered 21 | for any model, as well as truly custom events that let you send arbitrary 22 | payloads. 23 | 24 | By default, this library will just POST Django's JSON serialization of a model, 25 | but you can alternatively provide a `serialize_hook` method to customize payloads. 26 | 27 | *Please note:* this package does not implement any UI/API code, it only 28 | provides a handy framework or reference implementation for which to build upon. 29 | If you want to make a Django form or API resource, you'll need to do that yourself 30 | (though we've provided some example bits of code below). 31 | 32 | 33 | ### Development 34 | 35 | Running the tests for Django REST Hooks is very easy, just: 36 | 37 | ``` 38 | git clone https://github.com/zapier/django-rest-hooks && cd django-rest-hooks 39 | ``` 40 | 41 | Next, you'll want to make a virtual environment (we recommend using virtualenvwrapper 42 | but you could skip this we suppose) and then install dependencies: 43 | 44 | ``` 45 | mkvirtualenv django-rest-hooks 46 | pip install -r devrequirements.txt 47 | ``` 48 | 49 | Now you can run the tests! 50 | 51 | ``` 52 | python runtests.py 53 | ``` 54 | 55 | 56 | ### Installing & Configuring 57 | 58 | We recommend pip to install Django REST Hooks: 59 | 60 | ``` 61 | pip install django-rest-hooks 62 | ``` 63 | 64 | Next, you'll need to add `rest_hooks` to `INSTALLED_APPS` and configure 65 | you `HOOK_EVENTS` setting: 66 | 67 | ```python 68 | ### settings.py ### 69 | 70 | INSTALLED_APPS = ( 71 | # other apps here... 72 | 'rest_hooks', 73 | ) 74 | 75 | HOOK_EVENTS = { 76 | # 'any.event.name': 'App.Model.Action' (created/updated/deleted) 77 | 'book.added': 'bookstore.Book.created', 78 | 'book.changed': 'bookstore.Book.updated', 79 | 'book.removed': 'bookstore.Book.deleted', 80 | # and custom events, no extra meta data needed 81 | 'book.read': None, 82 | 'user.logged_in': None 83 | } 84 | 85 | ### bookstore/models.py ### 86 | 87 | class Book(models.Model): 88 | # NOTE: it is important to have a user property 89 | # as we use it to help find and trigger each Hook 90 | user = models.ForeignKey('auth.User') 91 | # maybe user is off a related object, so try... 92 | # user = property(lambda self: self.intermediary.user) 93 | 94 | title = models.CharField(max_length=128) 95 | pages = models.PositiveIntegerField() 96 | fiction = models.BooleanField() 97 | 98 | # ... other fields here ... 99 | 100 | def serialize_hook(self, hook): 101 | # optional, there are serialization defaults 102 | # we recommend always sending the Hook 103 | # metadata along for the ride as well 104 | return { 105 | 'hook': hook.dict(), 106 | 'data': { 107 | 'id': self.id, 108 | 'title': self.title, 109 | 'pages': self.pages, 110 | 'fiction': self.fiction, 111 | # ... other fields here ... 112 | } 113 | } 114 | 115 | def mark_as_read(self): 116 | # models can also have custom defined events 117 | from rest_hooks.signals import hook_event 118 | hook_event.send( 119 | sender=self.__class__, 120 | event_name='book.read', 121 | obj=self # the Book object 122 | ) 123 | ``` 124 | 125 | For the simplest experience, you'll just piggyback off the standard ORM which will 126 | handle the basic `created`, `updated` and `deleted` signals & events: 127 | 128 | ```python 129 | >>> from django.contrib.auth.models import User 130 | >>> from rest_hooks.model import Hook 131 | >>> jrrtolkien = User.objects.create(username='jrrtolkien') 132 | >>> hook = Hook(user=jrrtolkien, 133 | event='book.created', 134 | target='http://example.com/target.php') 135 | >>> hook.save() # creates the hook and stores it for later... 136 | >>> from bookstore.models import Book 137 | >>> book = Book(user=jrrtolkien, 138 | title='The Two Towers', 139 | pages=327, 140 | fiction=True) 141 | >>> book.save() # fires off 'bookstore.Book.created' hook automatically 142 | ... 143 | ``` 144 | 145 | Now that the book has been created, `http://example.com/target.php` will get: 146 | 147 | ``` 148 | POST http://example.com/target.php \ 149 | -H Content-Type: application/json \ 150 | -d '{"hook": { 151 | "id": 123, 152 | "event": "book.created", 153 | "target": "http://example.com/target.php"}, 154 | "data": { 155 | "title": "The Two Towers", 156 | "pages": 327, 157 | "fiction": true}}' 158 | ``` 159 | 160 | You can continue the example, triggering two more hooks in a similar method. However, 161 | since we have no hooks set up for `'book.changed'` or `'book.removed'`, they wouldn't get 162 | triggered anyways. 163 | 164 | ```python 165 | ... 166 | >>> book.title += ': Deluxe Edition' 167 | >>> book.pages = 352 168 | >>> book.save() # would fire off 'bookstore.Book.updated' hook automatically 169 | >>> book.delete() # would fire off 'bookstore.Book.deleted' hook automatically 170 | ``` 171 | 172 | You can also fire custom events with an arbitrary payload: 173 | 174 | ```python 175 | from rest_hooks.signals import raw_hook_event 176 | 177 | user = User.objects.get(id=123) 178 | raw_hook_event.send( 179 | sender=None, 180 | event_name='user.logged_in', 181 | payload={ 182 | 'username': user.username, 183 | 'email': user.email, 184 | 'when': datetime.datetime.now().isoformat() 185 | }, 186 | user=user # required: used to filter Hooks 187 | ) 188 | ``` 189 | 190 | 191 | ### How does it work? 192 | 193 | Django has a stellar [signals framework](https://docs.djangoproject.com/en/dev/topics/signals/), all 194 | REST Hooks does is register to receive all `post_save` (created/updated) and `post_delete` (deleted) 195 | signals. It then filters them down by: 196 | 197 | 1. Which `App.Model.Action` actually have an event registered in `settings.HOOK_EVENTS`. 198 | 2. After it verifies that a matching event exists, it searches for matching Hooks via the ORM. 199 | 3. Any Hooks that are found for the User/event combination get sent a payload via POST. 200 | 201 | 202 | ### How would you interact with it in the real world? 203 | 204 | **Let's imagine for a second that you've plugged REST Hooks into your API**. 205 | One could definitely provide a user interface to create hooks themselves via 206 | a standard browser & HTML based CRUD interface, but the real magic is when 207 | the Hook resource is part of an API. 208 | 209 | The basic target functionality is: 210 | 211 | ``` 212 | POST http://your-app.com/api/hooks?username=me&api_key=abcdef \ 213 | -H Content-Type: application/json \ 214 | -d '{"target": "http://example.com/target.php", 215 | "event": "book.created"}' 216 | ``` 217 | 218 | Now, whenever a Book is created (either via an ORM, a Django form, admin, etc...), 219 | `http://example.com/target.php` will get: 220 | 221 | ``` 222 | POST http://example.com/target.php \ 223 | -H Content-Type: application/json \ 224 | -d '{"hook": { 225 | "id": 123, 226 | "event": "book.created", 227 | "target": "http://example.com/target.php"}, 228 | "data": { 229 | "title": "Structure and Interpretation of Computer Programs", 230 | "pages": 657, 231 | "fiction": false}}' 232 | ``` 233 | 234 | *It is important to note that REST Hooks will handle all of this hook 235 | callback logic for you automatically.* 236 | 237 | But you can stop it anytime you like with a simple: 238 | 239 | ``` 240 | DELETE http://your-app.com/api/hooks/123?username=me&api_key=abcdef 241 | ``` 242 | 243 | If you already have a REST API, this should be relatively straightforward, 244 | but if not, Tastypie is a great choice. 245 | 246 | Some reference [Tastypie](http://tastypieapi.org/) or [Django REST framework](http://django-rest-framework.org/): + REST Hook code is below. 247 | 248 | #### Tastypie 249 | 250 | ```python 251 | ### resources.py ### 252 | 253 | from tastypie.resources import ModelResource 254 | from tastypie.authentication import ApiKeyAuthentication 255 | from tastypie.authorization import Authorization 256 | from rest_hooks.models import Hook 257 | 258 | class HookResource(ModelResource): 259 | def obj_create(self, bundle, request=None, **kwargs): 260 | return super(HookResource, self).obj_create(bundle, 261 | request, 262 | user=request.user) 263 | 264 | def apply_authorization_limits(self, request, object_list): 265 | return object_list.filter(user=request.user) 266 | 267 | class Meta: 268 | resource_name = 'hooks' 269 | queryset = Hook.objects.all() 270 | authentication = ApiKeyAuthentication() 271 | authorization = Authorization() 272 | allowed_methods = ['get', 'post', 'delete'] 273 | fields = ['event', 'target'] 274 | 275 | ### urls.py ### 276 | 277 | from tastypie.api import Api 278 | 279 | v1_api = Api(api_name='v1') 280 | v1_api.register(HookResource()) 281 | 282 | urlpatterns = patterns('', 283 | (r'^api/', include(v1_api.urls)), 284 | ) 285 | ``` 286 | #### Django REST framework 287 | 288 | ```python 289 | ### serializers.py ### 290 | 291 | from rest_framework import serializers 292 | 293 | from rest_hooks.models import Hook 294 | 295 | 296 | class HookSerializer(serializers.ModelSerializer): 297 | 298 | class Meta: 299 | model = Hook 300 | read_only_fields = ('user',) 301 | 302 | ### views.py ### 303 | 304 | from rest_framework import viewsets 305 | 306 | from rest_hooks.models import Hook 307 | 308 | from .serializers import HookSerializer 309 | 310 | 311 | class HookViewSet(viewsets.ModelViewSet): 312 | """ 313 | Retrieve, create, update or destroy webhooks. 314 | """ 315 | model = Hook 316 | serializer_class = HookSerializer 317 | 318 | def pre_save(self, obj): 319 | super(HookViewSet, self).pre_save(obj) 320 | obj.user = self.request.user 321 | 322 | ### urls.py ### 323 | 324 | from rest_framework import routers 325 | 326 | from . import views 327 | 328 | router = routers.SimpleRouter(trailing_slash=False) 329 | router.register(r'webhooks', views.HookViewSet, 'webhook') 330 | 331 | urlpatterns = router.urls 332 | ``` 333 | 334 | ### Some gotchas: 335 | 336 | Instead of doing blocking HTTP requests inside of signals, we've opted 337 | for a simple Threading pool that should handle the majority of use cases. 338 | 339 | However, if you use Celery, we'd *really* recommend using a simple task 340 | to handle this instead of threads. A quick example: 341 | 342 | ```python 343 | ### settings.py ### 344 | 345 | HOOK_DELIVERER = 'path.to.tasks.deliver_hook_wrapper' 346 | 347 | 348 | ### tasks.py ### 349 | 350 | from celery.task import Task 351 | import requests 352 | 353 | from django.utils import simplejson as json 354 | 355 | 356 | class DeliverHook(Task): 357 | def run(self, target, payload, instance=None, hook=None, **kwargs): 358 | """ 359 | target: the url to receive the payload. 360 | payload: a python primitive data structure 361 | instance: a possibly null "trigger" instance 362 | hook: the defining Hook object 363 | """ 364 | requests.post( 365 | url=target, 366 | data=json.dumps(payload), 367 | headers={'Content-Type': 'application/json'} 368 | ) 369 | 370 | deliver_hook_wrapper = DeliverHook.delay 371 | ``` 372 | 373 | We also don't handle retries or cleanup. Generally, if you get a 410 or 374 | a bunch of 4xx or 5xx, you should delete the Hook and let the user know. 375 | -------------------------------------------------------------------------------- /devrequirements.txt: -------------------------------------------------------------------------------- 1 | Django==1.8 2 | -e git+git@github.com:django/django-contrib-comments.git@07affdeed8bfce34761c6611573fe6ccce2c9f61#egg=django_contrib_comments-origin/HEAD 3 | requests==1.2.3 4 | mock==1.0.1 5 | -------------------------------------------------------------------------------- /rest_hooks/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = (1, 1, 1) 2 | -------------------------------------------------------------------------------- /rest_hooks/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from rest_hooks.models import Hook 4 | 5 | 6 | class HookAdmin(admin.ModelAdmin): 7 | list_display = [f.name for f in Hook._meta.fields] 8 | raw_id_fields = ['user',] 9 | admin.site.register(Hook, HookAdmin) 10 | -------------------------------------------------------------------------------- /rest_hooks/client.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import collections 3 | 4 | import requests 5 | 6 | 7 | class FlushThread(threading.Thread): 8 | def __init__(self, client): 9 | threading.Thread.__init__(self) 10 | self.client = client 11 | 12 | def run(self): 13 | self.client.sync_flush() 14 | 15 | 16 | class Client(object): 17 | """ 18 | Manages a simple pool of threads to flush the queue of requests. 19 | """ 20 | def __init__(self, num_threads=3): 21 | self.queue = collections.deque() 22 | 23 | self.flush_lock = threading.Lock() 24 | self.num_threads = num_threads 25 | self.flush_threads = [FlushThread(self) for _ in range(self.num_threads)] 26 | self.total_sent = 0 27 | 28 | def enqueue(self, method, *args, **kwargs): 29 | self.queue.append((method, args, kwargs)) 30 | self.refresh_threads() 31 | 32 | def get(self, *args, **kwargs): 33 | self.enqueue('get', *args, **kwargs) 34 | 35 | def post(self, *args, **kwargs): 36 | self.enqueue('post', *args, **kwargs) 37 | 38 | def put(self, *args, **kwargs): 39 | self.enqueue('put', *args, **kwargs) 40 | 41 | def delete(self, *args, **kwargs): 42 | self.enqueue('delete', *args, **kwargs) 43 | 44 | def refresh_threads(self): 45 | with self.flush_lock: 46 | # refresh if there are jobs to do and no threads are alive 47 | if len(self.queue) > 0: 48 | to_refresh = [index for index, thread in enumerate(self.flush_threads) if not thread.is_alive()] 49 | for index in to_refresh: 50 | self.flush_threads[index] = FlushThread(self) 51 | self.flush_threads[index].start() 52 | 53 | def sync_flush(self): 54 | while len(self.queue) > 0: 55 | method, args, kwargs = self.queue.pop() 56 | getattr(requests, method)(*args, **kwargs) 57 | self.total_sent += 1 58 | -------------------------------------------------------------------------------- /rest_hooks/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | from django.conf import settings 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='Hook', 17 | fields=[ 18 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 19 | ('created', models.DateTimeField(auto_now_add=True)), 20 | ('updated', models.DateTimeField(auto_now=True)), 21 | ('event', models.CharField(db_index=True, max_length=64, verbose_name=b'Event', choices=[(b'customer.created', b'customer.created'), (b'customer.deleted', b'customer.deleted'), (b'customer.updated', b'customer.updated'), (b'invoice.created', b'invoice.created'), (b'invoice.deleted', b'invoice.deleted'), (b'invoice.updated', b'invoice.updated'), (b'plan.created', b'plan.created'), (b'plan.deleted', b'plan.deleted'), (b'plan.updated', b'plan.updated'), (b'proforma.created', b'proforma.created'), (b'proforma.deleted', b'proforma.deleted'), (b'proforma.updated', b'proforma.updated'), (b'provider.created', b'provider.created'), (b'provider.deleted', b'provider.deleted'), (b'provider.updated', b'provider.updated'), (b'subscription.created', b'subscription.created'), (b'subscription.deleted', b'subscription.deleted'), (b'subscription.updated', b'subscription.updated')])), 22 | ('target', models.URLField(max_length=255, verbose_name=b'Target URL')), 23 | ('global_hook', models.BooleanField(default=False, help_text=b'Fire the hook, regardless of user owning the object.', verbose_name=b'Global hook')), 24 | ('user', models.ForeignKey(related_name='hooks', to=settings.AUTH_USER_MODEL)), 25 | ], 26 | ), 27 | migrations.AlterUniqueTogether( 28 | name='hook', 29 | unique_together=set([('user', 'event', 'target', 'global_hook')]), 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /rest_hooks/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/presslabs/django-rest-hooks-ng/7943147a43c1a08184077979636e26540c87b235/rest_hooks/migrations/__init__.py -------------------------------------------------------------------------------- /rest_hooks/models.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from django.conf import settings 4 | from django.core import serializers 5 | from django.db import models 6 | 7 | try: 8 | # Django <= 1.6 backwards compatibility 9 | from django.utils import simplejson as json 10 | except ImportError: 11 | # Django >= 1.7 12 | import json 13 | 14 | from rest_hooks.utils import get_module, find_and_fire_hook, distill_model_event 15 | 16 | from rest_hooks import signals 17 | 18 | 19 | HOOK_EVENTS = getattr(settings, 'HOOK_EVENTS', None) 20 | if HOOK_EVENTS is None: 21 | raise Exception('You need to define settings.HOOK_EVENTS!') 22 | 23 | if getattr(settings, 'HOOK_THREADING', True): 24 | from rest_hooks.client import Client 25 | client = Client() 26 | else: 27 | client = requests 28 | 29 | AUTH_USER_MODEL = getattr(settings, 'AUTH_USER_MODEL', 'auth.User') 30 | 31 | 32 | class Hook(models.Model): 33 | """ 34 | Stores a representation of a Hook. 35 | """ 36 | created = models.DateTimeField(auto_now_add=True) 37 | updated = models.DateTimeField(auto_now=True) 38 | 39 | user = models.ForeignKey(AUTH_USER_MODEL, related_name='hooks') 40 | event = models.CharField('Event', max_length=64, 41 | db_index=True, 42 | choices=[(e, e) for e in 43 | sorted(HOOK_EVENTS.keys())]) 44 | target = models.URLField('Target URL', max_length=255) 45 | global_hook = models.BooleanField(default=False, 46 | verbose_name='Global hook', 47 | help_text='Fire the hook, regardless of ' 48 | 'user owning the object.') 49 | 50 | def dict(self): 51 | return { 52 | 'id': self.id, 53 | 'event': self.event, 54 | 'target': self.target 55 | } 56 | 57 | def serialize_hook(self, instance): 58 | """ 59 | Serialize the object down to Python primitives. 60 | 61 | By default it uses Django's built in serializer. 62 | """ 63 | if getattr(instance, 'serialize_hook', None) and callable(instance.serialize_hook): 64 | return instance.serialize_hook(hook=self) 65 | if getattr(settings, 'HOOK_SERIALIZER', None): 66 | serializer = get_module(settings.HOOK_SERIALIZER) 67 | return serializer(instance, hook=self) 68 | # if no user defined serializers, fallback to the django builtin! 69 | return { 70 | 'hook': self.dict(), 71 | 'data': serializers.serialize('python', [instance])[0] 72 | } 73 | 74 | def deliver_hook(self, instance, payload_override=None): 75 | """ 76 | Deliver the payload to the target URL. 77 | 78 | By default it serializes to JSON and POSTs. 79 | """ 80 | payload = payload_override or self.serialize_hook(instance) 81 | if getattr(settings, 'HOOK_DELIVERER', None): 82 | deliverer = get_module(settings.HOOK_DELIVERER) 83 | deliverer(self.target, payload, instance=instance, hook=self) 84 | else: 85 | client.post( 86 | url=self.target, 87 | data=json.dumps(payload, cls=serializers.json.DjangoJSONEncoder), 88 | headers={'Content-Type': 'application/json'} 89 | ) 90 | 91 | signals.hook_sent_event.send_robust(sender=self.__class__, payload=payload, instance=instance, hook=self) 92 | return None 93 | 94 | def __unicode__(self): 95 | return u'{} => {}'.format(self.event, self.target) 96 | 97 | class Meta: 98 | unique_together = (("user", "event", "target", "global_hook"),) 99 | 100 | ############## 101 | ### EVENTS ### 102 | ############## 103 | 104 | from django.db.models.signals import post_save, post_delete 105 | from django.dispatch import receiver 106 | 107 | from rest_hooks.signals import hook_event, raw_hook_event 108 | 109 | 110 | get_opts = lambda m: m._meta.concrete_model._meta 111 | 112 | @receiver(post_save, dispatch_uid='instance-saved-hook') 113 | def model_saved(sender, instance, 114 | created, 115 | raw, 116 | using, 117 | **kwargs): 118 | """ 119 | Automatically triggers "created" and "updated" actions. 120 | """ 121 | opts = get_opts(instance) 122 | model = '.'.join([opts.app_label, opts.object_name]) 123 | action = 'created' if created else 'updated' 124 | distill_model_event(instance, model, action) 125 | 126 | @receiver(post_delete, dispatch_uid='instance-deleted-hook') 127 | def model_deleted(sender, instance, 128 | using, 129 | **kwargs): 130 | """ 131 | Automatically triggers "deleted" actions. 132 | """ 133 | opts = get_opts(instance) 134 | model = '.'.join([opts.app_label, opts.object_name]) 135 | distill_model_event(instance, model, 'deleted') 136 | 137 | @receiver(hook_event, dispatch_uid='instance-custom-hook') 138 | def custom_action(sender, action, 139 | instance, 140 | user=None, 141 | **kwargs): 142 | """ 143 | Manually trigger a custom action (or even a standard action). 144 | """ 145 | opts = get_opts(instance) 146 | model = '.'.join([opts.app_label, opts.object_name]) 147 | distill_model_event(instance, model, action, user_override=user) 148 | 149 | @receiver(raw_hook_event, dispatch_uid='raw-custom-hook') 150 | def raw_custom_event(sender, event_name, 151 | payload, 152 | user, 153 | send_hook_meta=True, 154 | instance=None, 155 | **kwargs): 156 | """ 157 | Give a full payload 158 | """ 159 | hooks = Hook.objects.filter(user=user, event=event_name) 160 | 161 | for hook in hooks: 162 | new_payload = payload 163 | if send_hook_meta: 164 | new_payload = { 165 | 'hook': hook.dict(), 166 | 'data': payload 167 | } 168 | 169 | hook.deliver_hook(instance, payload_override=new_payload) 170 | -------------------------------------------------------------------------------- /rest_hooks/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import Signal 2 | 3 | 4 | hook_event = Signal(providing_args=['action', 'instance']) 5 | raw_hook_event = Signal(providing_args=['event_name', 'payload', 'user']) 6 | hook_sent_event = Signal(providing_args=['payload', 'instance', 'hook']) 7 | -------------------------------------------------------------------------------- /rest_hooks/tasks.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import json 3 | 4 | from celery.task import Task 5 | 6 | from django.core.serializers.json import DjangoJSONEncoder 7 | 8 | from rest_hooks.models import Hook 9 | 10 | 11 | class DeliverHook(Task): 12 | def run(self, target, payload, instance=None, hook_id=None, **kwargs): 13 | """ 14 | target: the url to receive the payload. 15 | payload: a python primitive data structure 16 | instance: a possibly null "trigger" instance 17 | hook: the defining Hook object (useful for removing) 18 | """ 19 | response = requests.post( 20 | url=target, 21 | data=json.dumps(payload, cls=DjangoJSONEncoder), 22 | headers={'Content-Type': 'application/json'} 23 | ) 24 | 25 | if response.status_code == 410 and hook_id: 26 | hook = Hook.object.get(id=hook_id) 27 | hook.delete() 28 | 29 | # would be nice to log this, at least for a little while... 30 | 31 | def deliver_hook_wrapper(target, payload, instance=None, hook=None, **kwargs): 32 | if hook: 33 | kwargs['hook_id'] = hook.id 34 | return DeliverHook.delay(target, payload, **kwargs) 35 | -------------------------------------------------------------------------------- /rest_hooks/tests.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import time 3 | from mock import patch, MagicMock, ANY 4 | 5 | from datetime import datetime 6 | 7 | try: 8 | # Django <= 1.6 backwards compatibility 9 | from django.utils import simplejson as json 10 | except ImportError: 11 | # Django >= 1.7 12 | import json 13 | 14 | from django.conf import settings 15 | from django.contrib.auth.models import User 16 | from django_comments.models import Comment 17 | from django.contrib.sites.models import Site 18 | from django.test import TestCase 19 | 20 | from rest_hooks import models 21 | Hook = models.Hook 22 | 23 | from rest_hooks import signals 24 | 25 | 26 | class RESTHooksTest(TestCase): 27 | """ 28 | This test Class uses real HTTP calls to a requestbin service, making it easy 29 | to check responses and endpoint history. 30 | """ 31 | 32 | ############# 33 | ### TOOLS ### 34 | ############# 35 | 36 | def setUp(self): 37 | self.HOOK_EVENTS = getattr(settings, 'HOOK_EVENTS', None) 38 | self.HOOK_DELIVERER = getattr(settings, 'HOOK_DELIVERER', None) 39 | self.client = requests # force non-async for test cases 40 | 41 | self.user = User.objects.create_user('bob', 'bob@example.com', 'password') 42 | self.site = Site.objects.create(domain='example.com', name='example.com') 43 | 44 | models.HOOK_EVENTS = { 45 | 'comment.added': 'django_comments.Comment.created', 46 | 'comment.changed': 'django_comments.Comment.updated', 47 | 'comment.removed': 'django_comments.Comment.deleted', 48 | 'comment.moderated': 'django_comments.Comment.moderated', 49 | 'special.thing': None 50 | } 51 | 52 | settings.HOOK_DELIVERER = None 53 | 54 | def tearDown(self): 55 | models.HOOK_EVENTS = self.HOOK_EVENTS 56 | settings.HOOK_DELIVERER = self.HOOK_DELIVERER 57 | 58 | def make_hook(self, event, target): 59 | return Hook.objects.create( 60 | user=self.user, 61 | event=event, 62 | target=target 63 | ) 64 | 65 | ############# 66 | ### TESTS ### 67 | ############# 68 | 69 | def test_no_user_property_fail(self): 70 | self.assertRaises(models.find_and_fire_hook, args=('some.fake.event', self.user)) 71 | self.assertRaises(models.find_and_fire_hook, args=('special.thing', self.user)) 72 | 73 | def test_no_hook(self): 74 | comment = Comment.objects.create( 75 | site=self.site, 76 | content_object=self.user, 77 | user=self.user, 78 | comment='Hello world!' 79 | ) 80 | 81 | @patch('requests.post', autospec=True) 82 | def perform_create_request_cycle(self, method_mock): 83 | method_mock.return_value = None 84 | 85 | target = 'http://example.com/perform_create_request_cycle' 86 | hook = self.make_hook('comment.added', target) 87 | 88 | comment = Comment.objects.create( 89 | site=self.site, 90 | content_object=self.user, 91 | user=self.user, 92 | comment='Hello world!' 93 | ) 94 | # time.sleep(1) # should change a setting to turn off async 95 | 96 | return hook, comment, json.loads(method_mock.call_args_list[0][1]['data']) 97 | 98 | def test_simple_comment_hook(self): 99 | """ 100 | Uses the default serializer. 101 | """ 102 | hook, comment, payload = self.perform_create_request_cycle() 103 | 104 | self.assertEquals(hook.id, payload['hook']['id']) 105 | self.assertEquals('comment.added', payload['hook']['event']) 106 | self.assertEquals(hook.target, payload['hook']['target']) 107 | 108 | self.assertEquals(comment.id, payload['data']['pk']) 109 | self.assertEquals('Hello world!', payload['data']['fields']['comment']) 110 | self.assertEquals(comment.user.id, payload['data']['fields']['user']) 111 | 112 | def test_comment_hook_serializer_method(self): 113 | """ 114 | Use custom serialize_hook on the Comment model. 115 | """ 116 | def serialize_hook(comment, hook): 117 | return { 'hook': hook.dict(), 118 | 'data': { 'id': comment.id, 119 | 'comment': comment.comment, 120 | 'user': { 'username': comment.user.username, 121 | 'email': comment.user.email}}} 122 | Comment.serialize_hook = serialize_hook 123 | hook, comment, payload = self.perform_create_request_cycle() 124 | 125 | self.assertEquals(hook.id, payload['hook']['id']) 126 | self.assertEquals('comment.added', payload['hook']['event']) 127 | self.assertEquals(hook.target, payload['hook']['target']) 128 | 129 | self.assertEquals(comment.id, payload['data']['id']) 130 | self.assertEquals('Hello world!', payload['data']['comment']) 131 | self.assertEquals('bob', payload['data']['user']['username']) 132 | 133 | del Comment.serialize_hook 134 | 135 | @patch('requests.post') 136 | def test_full_cycle_comment_hook(self, method_mock): 137 | method_mock.return_value = None 138 | target = 'http://example.com/test_full_cycle_comment_hook' 139 | 140 | hooks = [self.make_hook(event, target) for event in ['comment.added', 'comment.changed', 'comment.removed']] 141 | 142 | # created 143 | comment = Comment.objects.create( 144 | site=self.site, 145 | content_object=self.user, 146 | user=self.user, 147 | comment='Hello world!' 148 | ) 149 | # time.sleep(0.5) # should change a setting to turn off async 150 | 151 | # updated 152 | comment.comment = 'Goodbye world...' 153 | comment.save() 154 | # time.sleep(0.5) # should change a setting to turn off async 155 | 156 | # deleted 157 | comment.delete() 158 | # time.sleep(0.5) # should change a setting to turn off async 159 | 160 | payloads = [json.loads(call[2]['data']) for call in method_mock.mock_calls] 161 | 162 | self.assertEquals('comment.added', payloads[0]['hook']['event']) 163 | self.assertEquals('comment.changed', payloads[1]['hook']['event']) 164 | self.assertEquals('comment.removed', payloads[2]['hook']['event']) 165 | 166 | self.assertEquals('Hello world!', payloads[0]['data']['fields']['comment']) 167 | self.assertEquals('Goodbye world...', payloads[1]['data']['fields']['comment']) 168 | self.assertEquals('Goodbye world...', payloads[2]['data']['fields']['comment']) 169 | 170 | @patch('requests.post') 171 | def test_custom_instance_hook(self, method_mock): 172 | from rest_hooks.signals import hook_event 173 | 174 | method_mock.return_value = None 175 | target = 'http://example.com/test_custom_instance_hook' 176 | 177 | hook = self.make_hook('comment.moderated', target) 178 | 179 | comment = Comment.objects.create( 180 | site=self.site, 181 | content_object=self.user, 182 | user=self.user, 183 | comment='Hello world!' 184 | ) 185 | 186 | hook_event.send( 187 | sender=comment.__class__, 188 | action='moderated', 189 | instance=comment 190 | ) 191 | # time.sleep(1) # should change a setting to turn off async 192 | 193 | payloads = [json.loads(call[2]['data']) for call in method_mock.mock_calls] 194 | 195 | self.assertEquals('comment.moderated', payloads[0]['hook']['event']) 196 | self.assertEquals('Hello world!', payloads[0]['data']['fields']['comment']) 197 | 198 | @patch('requests.post') 199 | def test_raw_custom_event(self, method_mock): 200 | from rest_hooks.signals import raw_hook_event 201 | 202 | method_mock.return_value = None 203 | target = 'http://example.com/test_raw_custom_event' 204 | 205 | hook = self.make_hook('special.thing', target) 206 | 207 | raw_hook_event.send( 208 | sender=None, 209 | event_name='special.thing', 210 | payload={ 211 | 'hello': 'world!' 212 | }, 213 | user=self.user 214 | ) 215 | # time.sleep(1) # should change a setting to turn off async 216 | 217 | payload = json.loads(method_mock.mock_calls[0][2]['data']) 218 | 219 | self.assertEquals('special.thing', payload['hook']['event']) 220 | self.assertEquals('world!', payload['data']['hello']) 221 | 222 | def test_timed_cycle(self): 223 | return # basically a debug test for thread pool bit 224 | target = 'http://requestbin.zapier.com/api/v1/bin/test_timed_cycle' 225 | 226 | hooks = [self.make_hook(event, target) for event in ['comment.added', 'comment.changed', 'comment.removed']] 227 | 228 | for n in range(4): 229 | early = datetime.now() 230 | # fires N * 3 http calls 231 | for x in range(10): 232 | comment = Comment.objects.create( 233 | site=self.site, 234 | content_object=self.user, 235 | user=self.user, 236 | comment='Hello world!' 237 | ) 238 | comment.comment = 'Goodbye world...' 239 | comment.save() 240 | comment.delete() 241 | total = datetime.now() - early 242 | 243 | print(total) 244 | 245 | while True: 246 | response = requests.get(target + '/view') 247 | sent = response.json 248 | if sent: 249 | print(len(sent), models.async_requests.total_sent) 250 | if models.async_requests.total_sent >= (30 * (n+1)): 251 | time.sleep(5) 252 | break 253 | time.sleep(1) 254 | 255 | requests.delete(target + '/view') # cleanup to be polite 256 | 257 | def test_signal_emitted_upon_success(self): 258 | wrapper = lambda *args, **kwargs: None 259 | mock_handler = MagicMock(wraps=wrapper) 260 | 261 | signals.hook_sent_event.connect(mock_handler, sender=Hook) 262 | 263 | hook, comment, payload = self.perform_create_request_cycle() 264 | 265 | payload['data']['fields']['submit_date'] = ANY 266 | mock_handler.assert_called_with(signal=ANY, sender=Hook, payload=payload, instance=comment, hook=hook) 267 | -------------------------------------------------------------------------------- /rest_hooks/utils.py: -------------------------------------------------------------------------------- 1 | def get_module(path): 2 | """ 3 | A modified duplicate from Django's built in backend 4 | retriever. 5 | 6 | slugify = get_module('django.template.defaultfilters.slugify') 7 | """ 8 | from django.utils.importlib import import_module 9 | 10 | try: 11 | mod_name, func_name = path.rsplit('.', 1) 12 | mod = import_module(mod_name) 13 | except ImportError as e: 14 | raise ImportError( 15 | 'Error importing alert function {0}: "{1}"'.format(mod_name, e)) 16 | 17 | try: 18 | func = getattr(mod, func_name) 19 | except AttributeError: 20 | raise ImportError( 21 | ('Module "{0}" does not define a "{1}" function' 22 | ).format(mod_name, func_name)) 23 | 24 | return func 25 | 26 | def find_and_fire_hook(event_name, instance, user_override=None): 27 | """ 28 | Look up Hooks that apply 29 | """ 30 | from django.db.models import Q 31 | from django.contrib.auth.models import User 32 | from rest_hooks.models import Hook, HOOK_EVENTS 33 | 34 | if user_override: 35 | user = user_override 36 | elif hasattr(instance, 'user'): 37 | user = instance.user 38 | elif isinstance(instance, User): 39 | user = instance 40 | else: 41 | user = None 42 | 43 | if not event_name in HOOK_EVENTS.keys(): 44 | raise Exception( 45 | '"{}" does not exist in `settings.HOOK_EVENTS`.'.format(event_name) 46 | ) 47 | 48 | hooks = Hook.objects.filter(Q(global_hook=True) | Q(user=user), 49 | event=event_name) 50 | 51 | for hook in hooks: 52 | hook.deliver_hook(instance) 53 | 54 | 55 | def distill_model_event(instance, model, action, user_override=None): 56 | """ 57 | Take created, updated and deleted actions for built-in 58 | app/model mappings, convert to the defined event.name 59 | and let hooks fly. 60 | 61 | If that model isn't represented, we just quit silenty. 62 | """ 63 | from rest_hooks.models import HOOK_EVENTS 64 | 65 | event_name = None 66 | for maybe_event_name, auto in HOOK_EVENTS.items(): 67 | if auto: 68 | # break auto into App.Model, Action 69 | maybe_model, maybe_action = auto.rsplit('.', 1) 70 | if model == maybe_model and action == maybe_action: 71 | event_name = maybe_event_name 72 | 73 | if event_name: 74 | find_and_fire_hook(event_name, instance, user_override=user_override) 75 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import sys 4 | import django 5 | 6 | from django.conf import settings 7 | from django.apps import apps 8 | 9 | 10 | APP_NAME = 'rest_hooks' 11 | 12 | settings.configure( 13 | DEBUG=True, 14 | DATABASES={ 15 | 'default': { 16 | 'ENGINE': 'django.db.backends.sqlite3', 17 | } 18 | }, 19 | USE_TZ=True, 20 | ROOT_URLCONF='{0}.tests'.format(APP_NAME), 21 | MIDDLEWARE_CLASSES=( 22 | 'django.contrib.sessions.middleware.SessionMiddleware', 23 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 24 | ), 25 | SITE_ID=1, 26 | HOOK_EVENTS={}, 27 | HOOK_THREADING=False, 28 | INSTALLED_APPS=( 29 | 'django.contrib.auth', 30 | 'django.contrib.contenttypes', 31 | 'django.contrib.sessions', 32 | 'django.contrib.admin', 33 | 'django.contrib.sites', 34 | 'django_comments', 35 | APP_NAME, 36 | ), 37 | ) 38 | 39 | if hasattr(django, 'setup'): 40 | django.setup() 41 | 42 | from django.test.utils import get_runner 43 | TestRunner = get_runner(settings) 44 | test_runner = TestRunner() 45 | failures = test_runner.run_tests([APP_NAME]) 46 | if failures: 47 | sys.exit(failures) 48 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup # setuptools breaks 2 | 3 | # Dynamically calculate the version 4 | version_tuple = __import__('rest_hooks').VERSION 5 | version = '.'.join([str(v) for v in version_tuple]) 6 | 7 | setup( 8 | name = 'django-rest-hooks-ng', 9 | description = 'A powerful mechanism for sending real time API notifications via a new subscription model.', 10 | version = version, 11 | author = 'Bryan Helmig, Calin Don', 12 | author_email = 'calin@presslabs.com', 13 | url = 'http://github.com/PressLabs/django-rest-hooks-ng', 14 | install_requires=['Django>=1.4','requests'], 15 | packages=['rest_hooks'], 16 | package_data={ 17 | 'rest_hooks': [ 18 | 'migrations/*.py' 19 | ] 20 | }, 21 | classifiers = ['Development Status :: 3 - Alpha', 22 | 'Environment :: Web Environment', 23 | 'Framework :: Django', 24 | 'Intended Audience :: Developers', 25 | 'License :: OSI Approved :: BSD License', 26 | 'Operating System :: OS Independent', 27 | 'Programming Language :: Python', 28 | 'Topic :: Utilities'], 29 | ) 30 | --------------------------------------------------------------------------------