Skip to content

Commit

Permalink
refactored handling of remapping (+completed test coverage)
Browse files Browse the repository at this point in the history
  • Loading branch information
caronc committed May 11, 2024
1 parent 29a9a18 commit 42bbee7
Show file tree
Hide file tree
Showing 6 changed files with 189 additions and 30 deletions.
46 changes: 45 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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.

17 changes: 10 additions & 7 deletions apprise_api/api/payload_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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]
Expand All @@ -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
28 changes: 28 additions & 0 deletions apprise_api/api/tests/test_payload_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
56 changes: 45 additions & 11 deletions apprise_api/api/tests/test_stateful_notify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
}
Expand All @@ -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',
}
Expand Down Expand Up @@ -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,
}

Expand All @@ -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',
}
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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,
}
Expand All @@ -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',
}
Expand Down Expand Up @@ -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,
}
Expand All @@ -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',
}
Expand Down
33 changes: 33 additions & 0 deletions apprise_api/api/tests/test_stateless_notify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
39 changes: 28 additions & 11 deletions apprise_api/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
Expand Down Expand Up @@ -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'):
Expand Down Expand Up @@ -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)

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit 42bbee7

Please sign in to comment.