File: README.md

package info (click to toggle)
python-django-rest-hooks 1.5.0-1
  • links: PTS, VCS
  • area: main
  • in suites: buster
  • size: 200 kB
  • sloc: python: 635; makefile: 6
file content (442 lines) | stat: -rw-r--r-- 13,408 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
[![Travis CI Build](https://img.shields.io/travis/zapier/django-rest-hooks/master.svg)](https://travis-ci.org/zapier/django-rest-hooks)
[![PyPI Download](https://img.shields.io/pypi/v/django-rest-hooks.svg)](https://pypi.python.org/pypi/django-rest-hooks)
[![PyPI Status](https://img.shields.io/pypi/status/django-rest-hooks.svg)](https://pypi.python.org/pypi/django-rest-hooks)
## What are Django REST Hooks?


REST Hooks are fancier versions of webhooks. Traditional webhooks are usually
managed manually by the user, but REST Hooks are not! They encourage RESTful
access to the hooks (or subscriptions) themselves. Add one, two or 15 hooks for
any combination of event and URLs, then get notificatied in real-time by our
bundled threaded callback mechanism.

The best part is: by reusing Django's great signals framework, this library is
dead simple. Here's how to get started:

1. Add `'rest_hooks'` to installed apps in settings.py.
2. Define your `HOOK_EVENTS` in settings.py.
3. Start sending hooks!

Using our **built-in actions**, zero work is required to support *any* basic `created`,
`updated`, and `deleted` actions across any Django model. We also allow for
**custom actions** (IE: beyond **C**R**UD**) to be simply defined and triggered
for any model, as well as truly custom events that let you send arbitrary
payloads.

By default, this library will just POST Django's JSON serialization of a model,
but you can alternatively provide a `serialize_hook` method to customize payloads.

*Please note:* this package does not implement any UI/API code, it only
provides a handy framework or reference implementation for which to build upon.
If you want to make a Django form or API resource, you'll need to do that yourself
(though we've provided some example bits of code below).


### Development

Running the tests for Django REST Hooks is very easy, just:

```
git clone https://github.com/zapier/django-rest-hooks && cd django-rest-hooks
```

Next, you'll want to make a virtual environment (we recommend using virtualenvwrapper
but you could skip this we suppose) and then install dependencies:

```
mkvirtualenv django-rest-hooks
pip install -r devrequirements.txt
```

Now you can run the tests!

```
python runtests.py
```

### Requirements

* Python 2 or 3 (tested on 2.7, 3.3, 3.4, 3.6)
* Django 1.4+ (tested on 1.5, 1.6, 1.7, 1.8, 1.9, 1.10, 1.11, 2.0)

### Installing & Configuring

We recommend pip to install Django REST Hooks:

```
pip install django-rest-hooks
```

Next, you'll need to add `rest_hooks` to `INSTALLED_APPS` and configure
your `HOOK_EVENTS` setting:

```python
### settings.py ###

INSTALLED_APPS = (
    # other apps here...
    'rest_hooks',
)

HOOK_EVENTS = {
    # 'any.event.name': 'App.Model.Action' (created/updated/deleted)
    'book.added':       'bookstore.Book.created',
    'book.changed':     'bookstore.Book.updated+',
    'book.removed':     'bookstore.Book.deleted',
    # and custom events, no extra meta data needed
    'book.read':         'bookstore.Book.read',
    'user.logged_in':    None
}

### bookstore/models.py ###

class Book(models.Model):
    # NOTE: it is important to have a user property
    # as we use it to help find and trigger each Hook
    # which is specific to users. If you want a Hook to
    # be triggered for all users, add '+' to built-in Hooks
    # or pass user_override=False for custom_hook events
    user = models.ForeignKey('auth.User', on_delete=models.CASCADE)
    # maybe user is off a related object, so try...
    # user = property(lambda self: self.intermediary.user)

    title = models.CharField(max_length=128)
    pages = models.PositiveIntegerField()
    fiction = models.BooleanField()

    # ... other fields here ...

    def serialize_hook(self, hook):
        # optional, there are serialization defaults
        # we recommend always sending the Hook
        # metadata along for the ride as well
        return {
            'hook': hook.dict(),
            'data': {
                'id': self.id,
                'title': self.title,
                'pages': self.pages,
                'fiction': self.fiction,
                # ... other fields here ...
            }
        }

    def mark_as_read(self):
        # models can also have custom defined events
        from rest_hooks.signals import hook_event
        hook_event.send(
            sender=self.__class__,
            action='read',
            instance=self # the Book object
        )
```

For the simplest experience, you'll just piggyback off the standard ORM which will
handle the basic `created`, `updated` and `deleted` signals & events:

```python
>>> from django.contrib.auth.models import User
>>> from rest_hooks.models import Hook
>>> jrrtolkien = User.objects.create(username='jrrtolkien')
>>> hook = Hook(user=jrrtolkien,
                event='book.added',
                target='http://example.com/target.php')
>>> hook.save()     # creates the hook and stores it for later...
>>> from bookstore.models import Book
>>> book = Book(user=jrrtolkien,
                title='The Two Towers',
                pages=327,
                fiction=True)
>>> book.save()     # fires off 'bookstore.Book.created' hook automatically
...
```

> NOTE: If you try to register an invalid event hook (not listed on HOOK_EVENTS in settings.py)
you will get a **ValidationError**.

Now that the book has been created, `http://example.com/target.php` will get:

```
POST http://example.com/target.php \
    -H Content-Type: application/json \
    -d '{"hook": {
           "id":      123,
           "event":   "book.added",
           "target":  "http://example.com/target.php"},
         "data": {
           "title":   "The Two Towers",
           "pages":   327,
           "fiction": true}}'
```

You can continue the example, triggering two more hooks in a similar method. However,
since we have no hooks set up for `'book.changed'` or `'book.removed'`, they wouldn't get
triggered anyways.

```python
...
>>> book.title += ': Deluxe Edition'
>>> book.pages = 352
>>> book.save()     # would fire off 'bookstore.Book.updated' hook automatically
>>> book.delete()   # would fire off 'bookstore.Book.deleted' hook automatically
```

You can also fire custom events with an arbitrary payload:

```python
from rest_hooks.signals import raw_hook_event

user = User.objects.get(id=123)
raw_hook_event.send(
    sender=None,
    event_name='user.logged_in',
    payload={
        'username': user.username,
        'email': user.email,
        'when': datetime.datetime.now().isoformat()
    },
    user=user # required: used to filter Hooks
)
```


### How does it work?

Django has a stellar [signals framework](https://docs.djangoproject.com/en/dev/topics/signals/), all
REST Hooks does is register to receive all `post_save` (created/updated) and `post_delete` (deleted)
signals. It then filters them down by:

1. Which `App.Model.Action` actually have an event registered in `settings.HOOK_EVENTS`.
2. After it verifies that a matching event exists, it searches for matching Hooks via the ORM.
3. Any Hooks that are found for the User/event combination get sent a payload via POST.


### How would you interact with it in the real world?

**Let's imagine for a second that you've plugged REST Hooks into your API**.
One could definitely provide a user interface to create hooks themselves via
a standard browser & HTML based CRUD interface, but the real magic is when
the Hook resource is part of an API.

The basic target functionality is:

```shell
POST http://your-app.com/api/hooks?username=me&api_key=abcdef \
    -H Content-Type: application/json \
    -d '{"target":    "http://example.com/target.php",
         "event":     "book.added"}'
```

Now, whenever a Book is created (either via an ORM, a Django form, admin, etc...),
`http://example.com/target.php` will get:

```shell
POST http://example.com/target.php \
    -H Content-Type: application/json \
    -d '{"hook": {
           "id":      123,
           "event":   "book.added",
           "target":  "http://example.com/target.php"},
         "data": {
           "title":   "Structure and Interpretation of Computer Programs",
           "pages":   657,
           "fiction": false}}'
```

*It is important to note that REST Hooks will handle all of this hook
callback logic for you automatically.*

But you can stop it anytime you like with a simple:

```
DELETE http://your-app.com/api/hooks/123?username=me&api_key=abcdef
```

If you already have a REST API, this should be relatively straightforward,
but if not, Tastypie is a great choice.

Some reference [Tastypie](http://tastypieapi.org/) or [Django REST framework](http://django-rest-framework.org/): + REST Hook code is below.

#### Tastypie

```python
### resources.py ###

from tastypie.resources import ModelResource
from tastypie.authentication import ApiKeyAuthentication
from tastypie.authorization import Authorization
from rest_hooks.models import Hook

class HookResource(ModelResource):
    def obj_create(self, bundle, request=None, **kwargs):
        return super(HookResource, self).obj_create(bundle,
                                                    request,
                                                    user=request.user)

    def apply_authorization_limits(self, request, object_list):
        return object_list.filter(user=request.user)

    class Meta:
        resource_name = 'hooks'
        queryset = Hook.objects.all()
        authentication = ApiKeyAuthentication()
        authorization = Authorization()
        allowed_methods = ['get', 'post', 'delete']
        fields = ['event', 'target']

### urls.py ###

from tastypie.api import Api

v1_api = Api(api_name='v1')
v1_api.register(HookResource())

urlpatterns = patterns('',
    (r'^api/', include(v1_api.urls)),
)
```
#### Django REST framework (3.+)

```python
### serializers.py ###

from rest_framework import serializers

from rest_hooks.models import Hook


class HookSerializer(serializers.ModelSerializer):

    class Meta:
        model = Hook
        read_only_fields = ('user',)

### views.py ###

from rest_framework import viewsets

from rest_hooks.models import Hook

from .serializers import HookSerializer


class HookViewSet(viewsets.ModelViewSet):
    """
    Retrieve, create, update or destroy webhooks.
    """
    model = Hook
    serializer_class = HookSerializer

    def perform_create(self, serializer):
        serializer.save(user=self.request.user)

### urls.py ###

from rest_framework import routers

from . import views

router = routers.SimpleRouter(trailing_slash=False)
router.register(r'webhooks', views.HookViewSet, 'webhook')

urlpatterns = router.urls
```

### Some gotchas:

Instead of doing blocking HTTP requests inside of signals, we've opted
for a simple Threading pool that should handle the majority of use cases.

However, if you use Celery, we'd *really* recommend using a simple task
to handle this instead of threads. A quick example:

```python
### settings.py ###

HOOK_DELIVERER = 'path.to.tasks.deliver_hook_wrapper'


### tasks.py ###

from celery.task import Task

import json
import requests


class DeliverHook(Task):
    max_retries = 5

    def run(self, target, payload, instance_id=None, hook_id=None, **kwargs):
        """
        target:     the url to receive the payload.
        payload:    a python primitive data structure
        instance_id:   a possibly None "trigger" instance ID
        hook_id:       the ID of defining Hook object
        """
        try:
            response = requests.post(
                url=target,
                data=json.dumps(payload),
                headers={'Content-Type': 'application/json'}
            )
            if response.status_code >= 500:
                response.raise_for_response()
        except requests.ConnectionError:
            delay_in_seconds = 2 ** self.request.retries
            self.retry(countdown=delay_in_seconds)


def deliver_hook_wrapper(target, payload, instance, hook):
    # instance is None if using custom event, not built-in
    if instance is not None:
        instance_id = instance.id
    else:
        instance_id = None
    # pass ID's not objects because using pickle for objects is a bad thing
    kwargs = dict(target=target, payload=payload,
                  instance_id=instance_id, hook_id=hook.id)
    DeliverHook.apply_async(kwargs=kwargs)

```

We also don't handle retries or cleanup. Generally, if you get a `410` or
a bunch of `4xx` or `5xx`, you should delete the Hook and let the user know.

### Extend the Hook model:

The default `Hook` model fields can be extended using the `AbstractHook` model.
For example, to add a `is_active` field on your hooks:

```python
### models.py ###

from django.db import models
from rest_hooks.models import AbstractHook

class CustomHook(AbstractHook):
    is_active = models.BooleanField(default=True)
```

The extended `CustomHook` model can be combined with a the `HOOK_FINDER` setting
for advanced QuerySet filtering. 

```python
### settings.py ###

HOOK_FINDER = 'path.to.find_and_fire_hook'

### utils.py ###

from .models import CustomHook

def find_and_fire_hook(event_name, instance, **kwargs):
    filters = {
        'event': event_name,
        'is_active': True,
    }

    hooks = CustomHook.objects.filter(**filters)
    for hook in hooks:
        hook.deliver_hook(instance)
```