diff --git a/README.md b/README.md index 31d1777..021797b 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,7 @@ curl -X POST \ http://localhost:8000/notify ``` -### Persistent Storage Solution +### Persistent (Stateful) Storage Solution You can pre-save all of your Apprise configuration and/or set of Apprise URLs and associate them with a `{KEY}` of your choosing. Once set, the configuration persists for retrieval by the `apprise` [CLI tool](https://github.com/caronc/apprise/wiki/CLI_Usage) or any other custom integration you've set up. The built in website with comes with a user interface that you can use to leverage these API calls as well. Those who wish to build their own application around this can use the following API end points: @@ -512,3 +512,47 @@ a.add(config) a.notify('test message') ``` +## Third Party Webhook Support +It can be understandable that third party applications can't always publish the format expected by this API tool. To work-around this, you can re-map the fields just before they're processed. For example; consider that we expect the follow minimum payload items for a stateful notification: +```json +{ + "body": "Message body" +} +``` + +But what if your tool you're using is only capable of sending: +```json +{ + "subject": "My Title", + "payload": "My Body" +} +``` + +We would want to map `subject` to `title` in this case and `payload` to `body`. This can easily be done using the `:` (colon) argument when we prepare our payload: + +```bash +# Note the keyword arguments prefixed with a `:` (colon). These +# instruct the API to map the payload (which we may not have control over) +# to align with what the Apprise API expects. +# +# We also convert `subject` to `title` too: +curl -X POST \ + -F "subject=Mesage Title" \ + -F "payload=Message Body" \ + "http://localhost:8000/notify/{KEY}?:subject=title&:payload=body" + +``` + +Here is the JSON Version and tests out the Stateless query (which requires at a minimum the `urls` and `body`: +```bash +# We also convert `subject` to `title` too: +curl -X POST -d '{"href": "mailto://user:pass@gmail.com", "subject":"My Title", "payload":"Body"}' \ + -H "Content-Type: application/json" \ + "http://localhost:8000/notify/{KEY}?:subject=title&:payload=body&:href=urls" +``` + +The colon `:` prefix is the switch that starts the re-mapping rule engine. You can do 3 possible things with the rule engine: +1. `:existing_key=expected_key`: Rename an existing (expected) payload key to one Apprise expects +1. `:existing_key=`: By setting no value, the existing key is simply removed from the payload entirely +1. `:expected_key=A value to give it`: You can also fix an expected apprise key to a pre-generated string value. + diff --git a/apprise_api/api/payload_mapper.py b/apprise_api/api/payload_mapper.py index 35c5ee2..489662b 100644 --- a/apprise_api/api/payload_mapper.py +++ b/apprise_api/api/payload_mapper.py @@ -31,7 +31,7 @@ logger = logging.getLogger('django') -def remap_fields(rules, payload): +def remap_fields(rules, payload, form=None): """ Remaps fields in the payload provided based on the rules provided @@ -48,8 +48,11 @@ def remap_fields(rules, payload): """ - # First generate our allowed keys; only these can be mapped - allowed_keys = set(NotifyForm().fields.keys()) + # Prepare our Form (identifies our expected keys) + form = NotifyForm() if form is None else form + + # First generate our expected keys; only these can be mapped + expected_keys = set(form.fields.keys()) for _key, value in rules.items(): key = _key.lower() @@ -59,8 +62,8 @@ def remap_fields(rules, payload): continue vkey = value.lower() - if vkey in allowed_keys: - if key not in allowed_keys or vkey not in payload: + if vkey in expected_keys and key in payload: + if key not in expected_keys or vkey not in payload: # replace payload[vkey] = payload[key] del payload[key] @@ -71,8 +74,8 @@ def remap_fields(rules, payload): payload[vkey] = payload[key] payload[key] = _tmp - else: - # store + elif key in expected_keys or key in payload: + # assignment payload[key] = value return True diff --git a/apprise_api/api/tests/test_payload_mapper.py b/apprise_api/api/tests/test_payload_mapper.py index 3b36d99..da7dd4a 100644 --- a/apprise_api/api/tests/test_payload_mapper.py +++ b/apprise_api/api/tests/test_payload_mapper.py @@ -196,3 +196,31 @@ def test_remap_fields(self): 'format': 'markdown', 'body': 'the message', } + + + # + # mapping of fields don't align - test 6 + # + rules = { + 'payload': 'body', + 'fmt': 'format', + 'extra': 'tag', + } + payload = { + 'format': 'markdown', + 'type': 'info', + 'title': '', + 'body': '## test notifiction', + 'attachment': None, + 'tag': 'general', + 'tags': '', + } + + # Make a copy of our original payload + payload_orig = payload.copy() + + # Map our fields + remap_fields(rules, payload) + + # There are no rules applied since nothing aligned + assert payload == payload_orig diff --git a/apprise_api/api/tests/test_stateful_notify.py b/apprise_api/api/tests/test_stateful_notify.py index 63983cc..790cb50 100644 --- a/apprise_api/api/tests/test_stateful_notify.py +++ b/apprise_api/api/tests/test_stateful_notify.py @@ -27,6 +27,7 @@ from unittest.mock import patch, Mock from ..forms import NotifyForm from ..utils import ConfigCache +from json import dumps import os import re import apprise @@ -107,7 +108,7 @@ def test_stateful_configuration_io(self, mock_post): assert len(entries) == 3 form_data = { - 'body': '## test notifiction', + 'body': '## test notification', 'format': apprise.NotifyFormat.MARKDOWN, 'tag': 'general', } @@ -128,7 +129,40 @@ def test_stateful_configuration_io(self, mock_post): mock_post.reset_mock() form_data = { - 'body': '## test notifiction', + 'payload': '## test notification', + 'fmt': apprise.NotifyFormat.MARKDOWN, + 'extra': 'general', + } + + # We sent the notification successfully (use our rule mapping) + # FORM + response = self.client.post( + f'/notify/{key}/?:payload=body&:fmt=format&:extra=tag', + form_data) + assert response.status_code == 200 + assert mock_post.call_count == 1 + + mock_post.reset_mock() + + form_data = { + 'payload': '## test notification', + 'fmt': apprise.NotifyFormat.MARKDOWN, + 'extra': 'general', + } + + # We sent the notification successfully (use our rule mapping) + # JSON + response = self.client.post( + f'/notify/{key}/?:payload=body&:fmt=format&:extra=tag', + dumps(form_data), + content_type="application/json") + assert response.status_code == 200 + assert mock_post.call_count == 1 + + mock_post.reset_mock() + + form_data = { + 'body': '## test notification', 'format': apprise.NotifyFormat.MARKDOWN, 'tag': 'no-on-with-this-tag', } @@ -180,7 +214,7 @@ def test_stateful_configuration_io(self, mock_post): assert len(entries) == 3 form_data = { - 'body': '## test notifiction', + 'body': '## test notification', 'format': apprise.NotifyFormat.MARKDOWN, } @@ -204,7 +238,7 @@ def test_stateful_configuration_io(self, mock_post): # Test tagging now # form_data = { - 'body': '## test notifiction', + 'body': '## test notification', 'format': apprise.NotifyFormat.MARKDOWN, 'tag': 'general+json', } @@ -226,7 +260,7 @@ def test_stateful_configuration_io(self, mock_post): mock_post.reset_mock() form_data = { - 'body': '## test notifiction', + 'body': '## test notification', 'format': apprise.NotifyFormat.MARKDOWN, # Plus with space inbetween 'tag': 'general + json', @@ -248,7 +282,7 @@ def test_stateful_configuration_io(self, mock_post): mock_post.reset_mock() form_data = { - 'body': '## test notifiction', + 'body': '## test notification', 'format': apprise.NotifyFormat.MARKDOWN, # Space (AND) 'tag': 'general json', @@ -269,7 +303,7 @@ def test_stateful_configuration_io(self, mock_post): mock_post.reset_mock() form_data = { - 'body': '## test notifiction', + 'body': '## test notification', 'format': apprise.NotifyFormat.MARKDOWN, # Comma (OR) 'tag': 'general, devops', @@ -351,7 +385,7 @@ def test_stateful_group_dict_notify(self, mock_post): for tag in ('user1', 'user2'): form_data = { - 'body': '## test notifiction', + 'body': '## test notification', 'format': apprise.NotifyFormat.MARKDOWN, 'tag': tag, } @@ -374,7 +408,7 @@ def test_stateful_group_dict_notify(self, mock_post): # Now let's notify by our group form_data = { - 'body': '## test notifiction', + 'body': '## test notification', 'format': apprise.NotifyFormat.MARKDOWN, 'tag': 'mygroup', } @@ -448,7 +482,7 @@ def test_stateful_group_dictlist_notify(self, mock_post): for tag in ('user1', 'user2'): form_data = { - 'body': '## test notifiction', + 'body': '## test notification', 'format': apprise.NotifyFormat.MARKDOWN, 'tag': tag, } @@ -471,7 +505,7 @@ def test_stateful_group_dictlist_notify(self, mock_post): # Now let's notify by our group form_data = { - 'body': '## test notifiction', + 'body': '## test notification', 'format': apprise.NotifyFormat.MARKDOWN, 'tag': 'mygroup', } diff --git a/apprise_api/api/tests/test_stateless_notify.py b/apprise_api/api/tests/test_stateless_notify.py index 6ee0a98..3eccb71 100644 --- a/apprise_api/api/tests/test_stateless_notify.py +++ b/apprise_api/api/tests/test_stateless_notify.py @@ -120,6 +120,39 @@ def test_notify(self, mock_notify): # Reset our mock object mock_notify.reset_mock() + form_data = { + 'payload': '## test notification', + 'fmt': apprise.NotifyFormat.MARKDOWN, + 'extra': 'mailto://user:pass@hotmail.com', + } + + # We sent the notification successfully (use our rule mapping) + # FORM + response = self.client.post( + f'/notify/?:payload=body&:fmt=format&:extra=urls', + form_data) + assert response.status_code == 200 + assert mock_notify.call_count == 1 + + mock_notify.reset_mock() + + form_data = { + 'payload': '## test notification', + 'fmt': apprise.NotifyFormat.MARKDOWN, + 'extra': 'mailto://user:pass@hotmail.com', + } + + # We sent the notification successfully (use our rule mapping) + # JSON + response = self.client.post( + '/notify/?:payload=body&:fmt=format&:extra=urls', + json.dumps(form_data), + content_type="application/json") + assert response.status_code == 200 + assert mock_notify.call_count == 1 + + mock_notify.reset_mock() + # Long Filename attach_data = { 'attachment': SimpleUploadedFile( diff --git a/apprise_api/api/views.py b/apprise_api/api/views.py index ad2fb2e..3e569c2 100644 --- a/apprise_api/api/views.py +++ b/apprise_api/api/views.py @@ -669,7 +669,16 @@ def post(self, request, key): # our content content = {} if not json_payload: - form = NotifyForm(data=request.POST, files=request.FILES) + if rules: + # Create a copy + data = request.POST.copy() + remap_fields(rules, data) + + else: + # Just create a pointer + data = request.POST + + form = NotifyForm(data=data, files=request.FILES) if form.is_valid(): content.update(form.cleaned_data) @@ -679,6 +688,10 @@ def post(self, request, key): # load our JSON content content = json.loads(request.body.decode('utf-8')) + # Apply content rules + if rules: + remap_fields(rules, content) + except (RequestDataTooBig): # DATA_UPLOAD_MAX_MEMORY_SIZE exceeded it's value; this is usually the case # when there is a very large flie attachment that can't be pulled out of the @@ -724,10 +737,6 @@ def post(self, request, key): status=status ) - # Apply content rules - if rules: - remap_fields(rules, content) - # Handle Attachments attach = None if not content.get('attachment'): @@ -1183,8 +1192,16 @@ def post(self, request): # our content content = {} if not json_payload: - content = {} - form = NotifyByUrlForm(request.POST, request.FILES) + if rules: + # Create a copy + data = request.POST.copy() + remap_fields(rules, data, form=NotifyByUrlForm()) + + else: + # Just create a pointer + data = request.POST + + form = NotifyByUrlForm(data=data, files=request.FILES) if form.is_valid(): content.update(form.cleaned_data) @@ -1194,6 +1211,10 @@ def post(self, request): # load our JSON content content = json.loads(request.body.decode('utf-8')) + # Apply content rules + if rules: + remap_fields(rules, content, form=NotifyByUrlForm()) + except (RequestDataTooBig): # DATA_UPLOAD_MAX_MEMORY_SIZE exceeded it's value; this is usually the case # when there is a very large flie attachment that can't be pulled out of the @@ -1235,10 +1256,6 @@ def post(self, request): 'error': msg, }, encoder=JSONEncoder, safe=False, status=status) - # Apply content rules - if rules: - remap_fields(rules, content) - if not content.get('urls') and settings.APPRISE_STATELESS_URLS: # fallback to settings.APPRISE_STATELESS_URLS if no urls were # defined