better stateless support

This commit is contained in:
Chris Caron
2019-10-28 21:54:51 -04:00
parent 2f21c0761d
commit 9a3648ca30
7 changed files with 291 additions and 47 deletions

5
.gitignore vendored
View File

@ -15,8 +15,9 @@ dist/
downloads/ downloads/
eggs/ eggs/
.eggs/ .eggs/
lib64/ lib64
lib/ lib
var/
include/ include/
bin/ bin/
parts/ parts/

View File

@ -6,7 +6,7 @@ Take advantage of [Apprise](https://github.com/caronc/apprise) through your netw
- An incredibly lightweight gateway to Apprise. - An incredibly lightweight gateway to Apprise.
- A production ready micro-service at your disposal. - A production ready micro-service at your disposal.
Apprise API was designed easily fit into existing (and new) eco-systems that are looking for a simple notification solution. Apprise API was designed to easily fit into existing (and new) eco-systems that are looking for a simple notification solution.
[![Paypal](https://img.shields.io/badge/paypal-donate-green.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=MHANV39UZNQ5E) [![Paypal](https://img.shields.io/badge/paypal-donate-green.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=MHANV39UZNQ5E)
[![Discord](https://img.shields.io/discord/558793703356104724.svg?colorB=7289DA&label=Discord&logo=Discord&logoColor=7289DA&style=flat-square)](https://discord.gg/MMPeN2D) [![Discord](https://img.shields.io/discord/558793703356104724.svg?colorB=7289DA&label=Discord&logo=Discord&logoColor=7289DA&style=flat-square)](https://discord.gg/MMPeN2D)
@ -19,6 +19,7 @@ Apprise API was designed easily fit into existing (and new) eco-systems that are
| `/del/{KEY}` | Removes Apprise Configuration from the persistent store. | `/del/{KEY}` | Removes Apprise Configuration from the persistent store.
| `/get/{KEY}` | Returns the Apprise Configuration from the persistent store. This can be directly used with the *Apprise CLI* and/or the *AppriseConfig()* object ([see here for details](https://github.com/caronc/apprise/wiki/config)). | `/get/{KEY}` | Returns the Apprise Configuration from the persistent store. This can be directly used with the *Apprise CLI* and/or the *AppriseConfig()* object ([see here for details](https://github.com/caronc/apprise/wiki/config)).
| `/notify/{KEY}` | Sends a notification based on the Apprise Configuration associated with the specified *{KEY}*.<br/>*Parameters*<br/>:small_red_triangle: **body**: Your message body. This is the *only* required field.<br/>:small_red_triangle: **title**: Optionally define a title to go along with the *body*.<br/>:small_red_triangle: **type**: Defines the message type you want to send as. The valid options are `info`, `success`, `warning`, and `error`. If no *type* is specified then `info` is the default value used.<br/>:small_red_triangle: **tag**: Optionally notify only those tagged accordingly. | `/notify/{KEY}` | Sends a notification based on the Apprise Configuration associated with the specified *{KEY}*.<br/>*Parameters*<br/>:small_red_triangle: **body**: Your message body. This is the *only* required field.<br/>:small_red_triangle: **title**: Optionally define a title to go along with the *body*.<br/>:small_red_triangle: **type**: Defines the message type you want to send as. The valid options are `info`, `success`, `warning`, and `error`. If no *type* is specified then `info` is the default value used.<br/>:small_red_triangle: **tag**: Optionally notify only those tagged accordingly.
| `/notify/` | Similar to the `notify` API identified above except this one sends a stateless notification and requires no reference to a `{KEY}`. <br/>*Parameters*<br/>:small_red_triangle: **urls**: One or more URLs identifying where the notification should be sent to. If this field isn't specified then it automatically assumes the `settings.APPRISE_STATELESS_URLS` value or `APPRISE_STATELESS_URLS` environment variable.<br/>:small_red_triangle: **body**: Your message body. This is a required field.<br/>:small_red_triangle: **title**: Optionally define a title to go along with the *body*.<br/>:small_red_triangle: **type**: Defines the message type you want to send as. The valid options are `info`, `success`, `warning`, and `error`. If no *type* is specified then `info` is the default value used.
### API Notes ### API Notes
@ -35,6 +36,7 @@ The use of environment variables allow you to provide over-rides to default sett
| Variable | Description | | Variable | Description |
|--------------------- | ----------- | |--------------------- | ----------- |
| `APPRISE_CONFIG_DIR` | Defines the persistent store location of all configuration files saved. By default:<br/> - Content is written to the `apprise_api/var/config` directory when just using the _Django_ `manage runserver` script. | `APPRISE_CONFIG_DIR` | Defines the persistent store location of all configuration files saved. By default:<br/> - Content is written to the `apprise_api/var/config` directory when just using the _Django_ `manage runserver` script.
| `APPRISE_STATELESS_URLS` | A default set of URLs to notify when using the stateless `/notify` reference (no reference to `{KEY}` variables).
| `SECRET_KEY` | A Django variable acting as a *salt* for most things that require security. This API uses it for the hash sequences when writing the configuration files to disk. | `SECRET_KEY` | A Django variable acting as a *salt* for most things that require security. This API uses it for the hash sequences when writing the configuration files to disk.
| `ALLOWED_HOSTS` | A list of strings representing the host/domain names that this API can serve. This is a security measure to prevent HTTP Host header attacks, which are possible even under many seemingly-safe web server configurations. By default this is set to `*` allowing any host. Use space to delimit more then one host. | `ALLOWED_HOSTS` | A list of strings representing the host/domain names that this API can serve. This is a security measure to prevent HTTP Host header attacks, which are possible even under many seemingly-safe web server configurations. By default this is set to `*` allowing any host. Use space to delimit more then one host.
| `DEBUG` | This defaults to `False` however can be set to `True`if defined with a non-zero value (such as `1`). | `DEBUG` | This defaults to `False` however can be set to `True`if defined with a non-zero value (such as `1`).
@ -85,35 +87,11 @@ pytest apprise_api
## Micro-Service Integration ## Micro-Service Integration
Perhaps you run your own service and the only goal you have is to add notification support to it. Here is an example: Perhaps you run your own service and the only goal you have is to add notification support to it. Here is an example:
```python ```bash
import requests # Send your notifications
import os curl -X POST -d '{"urls": "mailto://user:pass@gmail.com", "body":"test message"}' \
-H "Content-Type: application/json" \
# Get your URLs from your end users. They just need http://localhost:8000/notify
# to be a comma/space separated list (if there is more than one).
# Perhaps they're located in an environment variable:
urls = os.environ.get('NOTIFICATION_URLS', 'windows://')
if urls:
# Think of a key that best describes your purpose and/or program.
# Alternatively; you can make the key based on the users so they
# can each store their configuration.
key = 'my-program-name'
# POST our data:
requests.post(
'http://localhost:8000/add/{}'.format(key),
data={'urls': urls},
)
```
Now when you want to trigger a notification (sent from the Apprise API server), just do the following:
```python
# The minimum notify requirements are to have just provided the 'body':
requests.post(
'http://localhost:8000/notify/{}'.format(key),
data={'body': 'test message'},
)
``` ```
## Apprise Integration ## Apprise Integration

View File

@ -40,6 +40,10 @@ NOTIFICATION_TYPES = (
(apprise.NotifyType.FAILURE, _('Failure')), (apprise.NotifyType.FAILURE, _('Failure')),
) )
URLS_MAX_LEN = 1024
URLS_PLACEHOLDER = 'mailto://user:pass@domain.com, ' \
'slack://tokena/tokenb/tokenc, ...'
class AddByUrlForm(forms.Form): class AddByUrlForm(forms.Form):
""" """
@ -51,11 +55,8 @@ class AddByUrlForm(forms.Form):
""" """
urls = forms.CharField( urls = forms.CharField(
label=_('URLs'), label=_('URLs'),
widget=forms.TextInput( widget=forms.TextInput(attrs={'placeholder': URLS_PLACEHOLDER}),
attrs={ max_length=URLS_MAX_LEN,
'placeholder': 'mailto://user:pass@domain.com, '
'slack://tokena/tokenb/tokenc, ...'}),
max_length=1024,
) )
@ -121,17 +122,14 @@ class NotifyForm(forms.Form):
return data return data
class NotifyByUrlForm(AddByUrlForm, NotifyForm): class NotifyByUrlForm(NotifyForm):
""" """
Same as the NotifyForm but additionally processes a string of URLs to Same as the NotifyForm but additionally processes a string of URLs to
notify directly. notify directly.
""" """
pass urls = forms.CharField(
label=_('URLs'),
widget=forms.TextInput(attrs={'placeholder': URLS_PLACEHOLDER}),
class NotifyByConfigForm(AddByConfigForm, NotifyForm): max_length=URLS_MAX_LEN,
""" required=False,
Same as the NotifyForm but additionally process a configuration file as )
well.
"""
pass

View File

@ -0,0 +1,177 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
#
# This code is licensed under the MIT License.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files(the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions :
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
from django.test import SimpleTestCase
from django.test.utils import override_settings
from unittest.mock import patch
from ..forms import NotifyByUrlForm
import json
import apprise
class StatelessNotifyTests(SimpleTestCase):
"""
Test stateless notifications
"""
@patch('apprise.Apprise.notify')
def test_notify(self, mock_notify):
"""
Test sending a simple notification
"""
# Set our return value
mock_notify.return_value = True
# Preare our form data
form_data = {
'urls': 'mailto://user:pass@hotmail.com',
'body': 'test notifiction',
}
# At a minimum 'body' is requred
form = NotifyByUrlForm(data=form_data)
assert form.is_valid()
response = self.client.post('/notify', form.cleaned_data)
assert response.status_code == 200
assert mock_notify.call_count == 1
@override_settings(APPRISE_STATELESS_URLS="windows://")
@patch('apprise.Apprise.notify')
def test_notify_default_urls(self, mock_notify):
"""
Test fallback to default URLS if none were otherwise specified
in the post
"""
# Set our return value
mock_notify.return_value = True
# Preare our form data (without url specified)
# content will fall back to default configuration
form_data = {
'body': 'test notifiction',
}
# At a minimum 'body' is requred
form = NotifyByUrlForm(data=form_data)
assert form.is_valid()
# This still works as the environment variable kicks in
response = self.client.post('/notify', form.cleaned_data)
assert response.status_code == 200
assert mock_notify.call_count == 1
@patch('apprise.Apprise.notify')
def test_notify_by_loaded_urls_with_json(self, mock_notify):
"""
Test sending a simple notification using JSON
"""
# Set our return value
mock_notify.return_value = True
# Preare our JSON data without any urls
json_data = {
'urls': '',
'body': 'test notifiction',
'type': apprise.NotifyType.WARNING,
}
# Send our empty notification as a JSON object
response = self.client.post(
'/notify',
data=json.dumps(json_data),
content_type='application/json',
)
# Nothing notified
assert response.status_code == 204
assert mock_notify.call_count == 0
# Preare our JSON data
json_data = {
'urls': 'mailto://user:pass@yahoo.ca',
'body': 'test notifiction',
'type': apprise.NotifyType.WARNING,
}
# Send our notification as a JSON object
response = self.client.post(
'/notify',
data=json.dumps(json_data),
content_type='application/json',
)
# Still supported
assert response.status_code == 200
assert mock_notify.call_count == 1
# Reset our count
mock_notify.reset_mock()
# Test sending a garbage JSON object
response = self.client.post(
'/notify/',
data="{",
content_type='application/json',
)
assert response.status_code == 400
assert mock_notify.call_count == 0
# Test sending with an invalid content type
response = self.client.post(
'/notify',
data="{}",
content_type='application/xml',
)
assert response.status_code == 400
assert mock_notify.call_count == 0
# Test sending without any content at all
response = self.client.post(
'/notify/',
data="{}",
content_type='application/json',
)
assert response.status_code == 400
assert mock_notify.call_count == 0
# Test sending without a body
json_data = {
'type': apprise.NotifyType.WARNING,
}
response = self.client.post(
'/notify',
data=json.dumps(json_data),
content_type='application/json',
)
assert response.status_code == 400
assert mock_notify.call_count == 0

View File

@ -44,4 +44,7 @@ urlpatterns = [
re_path( re_path(
r'^notify/(?P<key>[\w_-]{1,64})/?', r'^notify/(?P<key>[\w_-]{1,64})/?',
views.NotifyView.as_view(), name='notify'), views.NotifyView.as_view(), name='notify'),
re_path(
r'^notify/?',
views.StatelessNotifyView.as_view(), name='s_notify'),
] ]

View File

@ -25,6 +25,7 @@
from django.shortcuts import render from django.shortcuts import render
from django.http import HttpResponse from django.http import HttpResponse
from django.views import View from django.views import View
from django.conf import settings
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.cache import never_cache from django.views.decorators.cache import never_cache
from django.views.decorators.gzip import gzip_page from django.views.decorators.gzip import gzip_page
@ -33,6 +34,7 @@ from .utils import ConfigCache
from .forms import AddByUrlForm from .forms import AddByUrlForm
from .forms import AddByConfigForm from .forms import AddByConfigForm
from .forms import NotifyForm from .forms import NotifyForm
from .forms import NotifyByUrlForm
from tempfile import NamedTemporaryFile from tempfile import NamedTemporaryFile
import apprise import apprise
@ -428,3 +430,84 @@ class NotifyView(View):
return HttpResponse( return HttpResponse(
_('Notification(s) sent.'), _('Notification(s) sent.'),
content_type=content_type, status=ResponseCode.okay) content_type=content_type, status=ResponseCode.okay)
@method_decorator((gzip_page, never_cache), name='dispatch')
class StatelessNotifyView(View):
"""
A Django view for sending a stateless notification
"""
def post(self, request):
"""
Handle a POST request
"""
# Our default response type
content_type = 'text/plain; charset=utf-8'
# our content
content = {}
if MIME_IS_FORM.match(request.content_type):
content = {}
form = NotifyByUrlForm(request.POST)
if form.is_valid():
content.update(form.cleaned_data)
elif MIME_IS_JSON.match(request.content_type):
# Prepare our default response
try:
# load our JSON content
content = json.loads(request.body)
except (AttributeError, ValueError):
# could not parse JSON response...
return HttpResponse(
_('Invalid JSON specified.'),
content_type=content_type,
status=ResponseCode.bad_request)
if not content:
# We could not handle the Content-Type
return HttpResponse(
_('The message format is not supported.'),
content_type=content_type,
status=ResponseCode.bad_request)
if not content.get('urls') and settings.APPRISE_STATELESS_URLS:
# fallback to settings.APPRISE_STATELESS_URLS if no urls were
# defined
content['urls'] = settings.APPRISE_STATELESS_URLS
# Some basic error checking
if not content.get('body') or \
content.get('type', apprise.NotifyType.INFO) \
not in apprise.NOTIFY_TYPES:
return HttpResponse(
_('An invalid payload was specified.'),
content_type=content_type,
status=ResponseCode.bad_request)
# Prepare our apprise object
a_obj = apprise.Apprise()
# Add URLs
a_obj.add(content.get('urls'))
if not len(a_obj):
return HttpResponse(
_('There was no services to notify.'),
content_type=content_type,
status=ResponseCode.no_content,
)
# Perform our notification at this point
a_obj.notify(
content.get('body'),
title=content.get('title', ''),
notify_type=content.get('type', apprise.NotifyType.INFO),
tag='all',
)
# Return our retrieved content
return HttpResponse(
_('Notification(s) sent.'),
content_type=content_type, status=ResponseCode.okay)

View File

@ -83,3 +83,7 @@ STATIC_URL = '/s/'
# The location to store Apprise configuration files # The location to store Apprise configuration files
APPRISE_CONFIG_DIR = os.environ.get( APPRISE_CONFIG_DIR = os.environ.get(
'APPRISE_CONFIG_DIR', os.path.join(BASE_DIR, 'var', 'config')) 'APPRISE_CONFIG_DIR', os.path.join(BASE_DIR, 'var', 'config'))
# Stateless posts to /notify/ will resort to this set of URLs if none
# were otherwise posted with the URL request.
APPRISE_STATELESS_URLS = os.environ.get('APPRISE_STATELESS_URLS', '')