From 80ae7228e9bcf985aa0c53b8dc12decbf1e786d5 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Mon, 15 May 2023 15:21:03 -0400 Subject: [PATCH] Apprise API FORM based Attachment Support Added (#877) --- apprise/plugins/NotifyAppriseAPI.py | 109 ++++++++++++++++++++++------ test/test_plugin_apprise_api.py | 109 +++++++++++++++++----------- 2 files changed, 156 insertions(+), 62 deletions(-) diff --git a/apprise/plugins/NotifyAppriseAPI.py b/apprise/plugins/NotifyAppriseAPI.py index 8da9f6c6..b8765496 100644 --- a/apprise/plugins/NotifyAppriseAPI.py +++ b/apprise/plugins/NotifyAppriseAPI.py @@ -43,6 +43,20 @@ from ..utils import validate_regex from ..AppriseLocale import gettext_lazy as _ +class AppriseAPIMethod: + """ + Defines the method to post data tot he remote server + """ + JSON = 'json' + FORM = 'form' + + +APPRISE_API_METHODS = ( + AppriseAPIMethod.FORM, + AppriseAPIMethod.JSON, +) + + class NotifyAppriseAPI(NotifyBase): """ A wrapper for Apprise (Persistent) API Notifications @@ -65,7 +79,7 @@ class NotifyAppriseAPI(NotifyBase): # Depending on the number of transactions/notifications taking place, this # could take a while. 30 seconds should be enough to perform the task - socket_connect_timeout = 30.0 + socket_read_timeout = 30.0 # Disable throttle rate for Apprise API requests since they are normally # local anyway @@ -120,6 +134,12 @@ class NotifyAppriseAPI(NotifyBase): 'name': _('Tags'), 'type': 'string', }, + 'method': { + 'name': _('Query Method'), + 'type': 'choice:string', + 'values': APPRISE_API_METHODS, + 'default': APPRISE_API_METHODS[0], + }, 'to': { 'alias_of': 'token', }, @@ -133,7 +153,8 @@ class NotifyAppriseAPI(NotifyBase): }, } - def __init__(self, token=None, tags=None, headers=None, **kwargs): + def __init__(self, token=None, tags=None, method=None, headers=None, + **kwargs): """ Initialize Apprise API Object @@ -155,6 +176,14 @@ class NotifyAppriseAPI(NotifyBase): self.logger.warning(msg) raise TypeError(msg) + self.method = self.template_args['method']['default'] \ + if not isinstance(method, str) else method.lower() + + if self.method not in APPRISE_API_METHODS: + msg = 'The method specified ({}) is invalid.'.format(method) + self.logger.warning(msg) + raise TypeError(msg) + # Build list of tags self.__tags = parse_list(tags) @@ -170,8 +199,13 @@ class NotifyAppriseAPI(NotifyBase): Returns the URL built dynamically based on specified arguments. """ - # Our URL parameters - params = self.url_parameters(privacy=privacy, *args, **kwargs) + # Define any URL parameters + params = { + 'method': self.method, + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Append our headers into our parameters params.update({'+{}'.format(k): v for k, v in self.headers.items()}) @@ -219,16 +253,15 @@ class NotifyAppriseAPI(NotifyBase): # Prepare HTTP Headers headers = { 'User-Agent': self.app_id, - 'Content-Type': 'application/json' } # Apply any/all header over-rides defined headers.update(self.headers) - # Track our potential attachments attachments = [] + files = [] if attach: - for attachment in attach: + for no, attachment in enumerate(attach, start=1): # Perform some simple error checking if not attachment: # We could not access the attachment @@ -238,15 +271,26 @@ class NotifyAppriseAPI(NotifyBase): return False try: - with open(attachment.path, 'rb') as f: - # Output must be in a DataURL format (that's what - # PushSafer calls it): - attachments.append({ - 'filename': attachment.name, - 'base64': base64.b64encode(f.read()) - .decode('utf-8'), - 'mimetype': attachment.mimetype, - }) + if self.method == AppriseAPIMethod.JSON: + with open(attachment.path, 'rb') as f: + # Output must be in a DataURL format (that's what + # PushSafer calls it): + attachments.append({ + 'filename': attachment.name, + 'base64': base64.b64encode(f.read()) + .decode('utf-8'), + 'mimetype': attachment.mimetype, + }) + + else: # AppriseAPIMethod.FORM + files.append(( + 'file{:02d}'.format(no), + ( + attachment.name, + open(attachment.path, 'rb'), + attachment.mimetype, + ) + )) except (OSError, IOError) as e: self.logger.warning( @@ -262,9 +306,13 @@ class NotifyAppriseAPI(NotifyBase): 'body': body, 'type': notify_type, 'format': self.notify_format, - 'attachments': attachments, } + if self.method == AppriseAPIMethod.JSON: + headers['Content-Type'] = 'application/json' + payload['attachments'] = attachments + payload = dumps(payload) + if self.__tags: payload['tag'] = self.__tags @@ -285,8 +333,8 @@ class NotifyAppriseAPI(NotifyBase): # Some entries can not be over-ridden headers.update({ - 'User-Agent': self.app_id, - 'Content-Type': 'application/json', + # Our response to be in JSON format always + 'Accept': 'application/json', # Pass our Source UUID4 Identifier 'X-Apprise-ID': self.asset._uid, # Pass our current recursion count to our upstream server @@ -304,9 +352,10 @@ class NotifyAppriseAPI(NotifyBase): try: r = requests.post( url, - data=dumps(payload), + data=payload, headers=headers, auth=auth, + files=files if files else None, verify=self.verify_certificate, timeout=self.request_timeout, ) @@ -328,7 +377,8 @@ class NotifyAppriseAPI(NotifyBase): return False else: - self.logger.info('Sent Apprise API notification.') + self.logger.info( + 'Sent Apprise API notification; method=%s.', self.method) except requests.RequestException as e: self.logger.warning( @@ -339,6 +389,18 @@ class NotifyAppriseAPI(NotifyBase): # Return; we're done return False + except (OSError, IOError) as e: + self.logger.warning( + 'An I/O error occurred while reading one of the ' + 'attached files.') + self.logger.debug('I/O Exception: %s' % str(e)) + return False + + finally: + for file in files: + # Ensure all files are closed + file[1][1].close() + return True @staticmethod @@ -415,4 +477,9 @@ class NotifyAppriseAPI(NotifyBase): # re-assemble our full path results['fullpath'] = '/'.join(entries) + # Set method if specified + if 'method' in results['qsd'] and len(results['qsd']['method']): + results['method'] = \ + NotifyAppriseAPI.unquote(results['qsd']['method']) + return results diff --git a/test/test_plugin_apprise_api.py b/test/test_plugin_apprise_api.py index 4dd32e4c..3eb9cd50 100644 --- a/test/test_plugin_apprise_api.py +++ b/test/test_plugin_apprise_api.py @@ -142,6 +142,20 @@ apprise_url_tests = ( # Our expected url(privacy=True) startswith() response: 'privacy_url': 'apprises://localhost:8080/m...4/', }), + ('apprises://localhost:8080/abc123/?method=json', { + 'instance': NotifyAppriseAPI, + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'apprises://localhost:8080/a...3/', + }), + ('apprises://localhost:8080/abc123/?method=form', { + 'instance': NotifyAppriseAPI, + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'apprises://localhost:8080/a...3/', + }), + # Invalid method specified + ('apprises://localhost:8080/abc123/?method=invalid', { + 'instance': TypeError, + }), ('apprises://user:password@localhost:8080/mytoken5/', { 'instance': NotifyAppriseAPI, @@ -190,52 +204,65 @@ def test_notify_apprise_api_attachments(mock_post): """ okay_response = requests.Request() - okay_response.status_code = requests.codes.ok - okay_response.content = "" - # Assign our mock object our return value - mock_post.return_value = okay_response + for method in ('json', 'form'): + okay_response.status_code = requests.codes.ok + okay_response.content = "" - obj = Apprise.instantiate('apprise://user@localhost/mytoken1/') - assert isinstance(obj, NotifyAppriseAPI) + # Assign our mock object our return value + mock_post.return_value = okay_response - # Test Valid Attachment - path = os.path.join(TEST_VAR_DIR, 'apprise-test.gif') - attach = AppriseAttachment(path) - assert obj.notify( - body='body', title='title', notify_type=NotifyType.INFO, - attach=attach) is True + obj = Apprise.instantiate( + 'apprise://user@localhost/mytoken1/?method={}'.format(method)) + assert isinstance(obj, NotifyAppriseAPI) - # Test invalid attachment - path = os.path.join(TEST_VAR_DIR, '/invalid/path/to/an/invalid/file.jpg') - assert obj.notify( - body='body', title='title', notify_type=NotifyType.INFO, - attach=path) is False - - # Test Valid Attachment (load 3) - path = ( - os.path.join(TEST_VAR_DIR, 'apprise-test.gif'), - os.path.join(TEST_VAR_DIR, 'apprise-test.gif'), - os.path.join(TEST_VAR_DIR, 'apprise-test.gif'), - ) - attach = AppriseAttachment(path) - - # Return our good configuration - mock_post.side_effect = None - mock_post.return_value = okay_response - with mock.patch('builtins.open', side_effect=OSError()): - # We can't send the message we can't open the attachment for reading + # Test Valid Attachment + path = os.path.join(TEST_VAR_DIR, 'apprise-test.gif') + attach = AppriseAttachment(path) assert obj.notify( body='body', title='title', notify_type=NotifyType.INFO, - attach=attach) is False + attach=attach) is True - # test the handling of our batch modes - obj = Apprise.instantiate('apprise://user@localhost/mytoken1/') - assert isinstance(obj, NotifyAppriseAPI) + # Test invalid attachment + path = os.path.join( + TEST_VAR_DIR, '/invalid/path/to/an/invalid/file.jpg') + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=path) is False - # Now send an attachment normally without issues - mock_post.reset_mock() - assert obj.notify( - body='body', title='title', notify_type=NotifyType.INFO, - attach=attach) is True - assert mock_post.call_count == 1 + # Test Valid Attachment (load 3) + path = ( + os.path.join(TEST_VAR_DIR, 'apprise-test.gif'), + os.path.join(TEST_VAR_DIR, 'apprise-test.gif'), + os.path.join(TEST_VAR_DIR, 'apprise-test.gif'), + ) + attach = AppriseAttachment(path) + + # Return our good configuration + mock_post.side_effect = None + mock_post.return_value = okay_response + with mock.patch('builtins.open', side_effect=OSError()): + # We can't send the message we can't open the attachment for + # reading + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=attach) is False + + with mock.patch('requests.post', side_effect=OSError()): + # Attachment issue + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=attach) is False + + # test the handling of our batch modes + obj = Apprise.instantiate('apprise://user@localhost/mytoken1/') + assert isinstance(obj, NotifyAppriseAPI) + + # Now send an attachment normally without issues + mock_post.reset_mock() + + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=attach) is True + assert mock_post.call_count == 1 + mock_post.reset_mock()