From 0b901df8fc8c5de245cbf62a925471721647145c Mon Sep 17 00:00:00 2001 From: Subhransu Das Date: Sun, 12 May 2024 11:07:18 +0530 Subject: [PATCH 1/3] Added v1 endpoints for pyFCm in new v1 package. Added test for v1 baseapi --- pyfcm/v1/__init__.py | 0 pyfcm/v1/baseapi.py | 200 +++++++++++++++++++++++++++++++++++++++ pyfcm/v1/fcm.py | 6 ++ tests/test_v1_baseapi.py | 82 ++++++++++++++++ 4 files changed, 288 insertions(+) create mode 100644 pyfcm/v1/__init__.py create mode 100644 pyfcm/v1/baseapi.py create mode 100644 pyfcm/v1/fcm.py create mode 100644 tests/test_v1_baseapi.py diff --git a/pyfcm/v1/__init__.py b/pyfcm/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyfcm/v1/baseapi.py b/pyfcm/v1/baseapi.py new file mode 100644 index 0000000..c1b19d6 --- /dev/null +++ b/pyfcm/v1/baseapi.py @@ -0,0 +1,200 @@ +import os +import threading + +from pyfcm.baseapi import BaseAPI + +from google.oauth2 import service_account +import google.auth.transport.requests + +from pyfcm.errors import InvalidDataError + + +class BaseAPI(BaseAPI): + FCM_END_POINT = "https://fcm.googleapis.com/v1/projects" + + def __init__(self, service_account_file_path: str, project_id: str, proxy_dict=None, env=None, json_encoder=None, + adapter=None): + """ + Override existing init function to give ability to use v1 endpoints of Firebase Cloud Messaging API + Attributes: + service_account_file_path (str): path to service account JSON file + project_id (str): project ID of Google account + proxy_dict (dict): proxy settings dictionary, use proxy (keys: `http`, `https`) + env (dict): environment settings dictionary, for example "app_engine" + json_encoder (BaseJSONEncoder): JSON encoder + adapter (BaseAdapter): adapter instance + """ + self.service_account_file = service_account_file_path + self.project_id = project_id + self.FCM_END_POINT = self.FCM_END_POINT + f"/{self.project_id}/messages:send" + self.FCM_REQ_PROXIES = None + self.custom_adapter = adapter + self.thread_local = threading.local() + + if proxy_dict and isinstance(proxy_dict, dict) and (('http' in proxy_dict) or ('https' in proxy_dict)): + self.FCM_REQ_PROXIES = proxy_dict + self.requests_session.proxies.update(proxy_dict) + self.send_request_responses = [] + + if env == 'app_engine': + try: + from requests_toolbelt.adapters import appengine + appengine.monkeypatch() + except ModuleNotFoundError: + pass + + self.json_encoder = json_encoder + + def _get_access_token(self): + """ + Generates access from refresh token that contains in the service_account_file. + If token expires then new access token is generated. + Returns: + str: Access token + """ + # get OAuth 2.0 access token + try: + credentials = service_account.Credentials.from_service_account_file( + self.service_account_file, + scopes=['https://www.googleapis.com/auth/firebase.messaging']) + request = google.auth.transport.requests.Request() + credentials.refresh(request) + return credentials.token + except Exception as e: + raise InvalidDataError(e) + + def request_headers(self): + """ + Generates request headers including Content-Type and Authorization of Bearer token + + Returns: + dict: request headers + """ + return { + "Content-Type": self.CONTENT_TYPE, + "Authorization": "Bearer " + self._get_access_token(), + } + + def parse_payload(self, + registration_ids=None, + topic_name=None, + message_body=None, + message_title=None, + message_icon=None, + sound=None, + condition=None, + collapse_key=None, + delay_while_idle=False, + time_to_live=None, + restricted_package_name=None, + low_priority=False, + dry_run=False, + data_message=None, + click_action=None, + badge=None, + color=None, + tag=None, + body_loc_key=None, + body_loc_args=None, + title_loc_key=None, + title_loc_args=None, + content_available=None, + remove_notification=False, + **extra_kwargs): + + """ + + :rtype: json + """ + fcm_payload = dict() + if registration_ids: + if len(registration_ids) > 1: + fcm_payload['registration_ids'] = registration_ids + else: + fcm_payload['token'] = registration_ids[0] + if condition: + fcm_payload['condition'] = condition + else: + # In the `to` reference at: https://firebase.google.com/docs/cloud-messaging/http-server-ref#send-downstream + # We have `Do not set this field (to) when sending to multiple topics` + # Which is why it's in the `else` block since `condition` is used when multiple topics are being targeted + if topic_name: + fcm_payload['to'] = '/topics/%s' % topic_name + # if low_priority: + # fcm_payload['priority'] = self.FCM_LOW_PRIORITY + # else: + # fcm_payload['priority'] = self.FCM_HIGH_PRIORITY + + if delay_while_idle: + fcm_payload['delay_while_idle'] = delay_while_idle + if collapse_key: + fcm_payload['collapse_key'] = collapse_key + if time_to_live: + if isinstance(time_to_live, int): + fcm_payload['time_to_live'] = time_to_live + else: + raise InvalidDataError("Provided time_to_live is not an integer") + if restricted_package_name: + fcm_payload['restricted_package_name'] = restricted_package_name + if dry_run: + fcm_payload['dry_run'] = dry_run + + if data_message: + if isinstance(data_message, dict): + fcm_payload['message'] = data_message + else: + raise InvalidDataError("Provided data_message is in the wrong format") + + fcm_payload['notification'] = {} + if message_icon: + fcm_payload['notification']['icon'] = message_icon + # If body is present, use it + if message_body: + fcm_payload['notification']['body'] = message_body + # Else use body_loc_key and body_loc_args for body + else: + if body_loc_key: + fcm_payload['notification']['body_loc_key'] = body_loc_key + if body_loc_args: + if isinstance(body_loc_args, list): + fcm_payload['notification']['body_loc_args'] = body_loc_args + else: + raise InvalidDataError('body_loc_args should be an array') + # If title is present, use it + if message_title: + fcm_payload['notification']['title'] = message_title + # Else use title_loc_key and title_loc_args for title + else: + if title_loc_key: + fcm_payload['notification']['title_loc_key'] = title_loc_key + if title_loc_args: + if isinstance(title_loc_args, list): + fcm_payload['notification']['title_loc_args'] = title_loc_args + else: + raise InvalidDataError('title_loc_args should be an array') + + # This is needed for iOS when we are sending only custom data messages + if content_available and isinstance(content_available, bool): + fcm_payload['content_available'] = content_available + + if click_action: + fcm_payload['notification']['click_action'] = click_action + if badge: + fcm_payload['notification']['badge'] = badge + if color: + fcm_payload['notification']['color'] = color + if tag: + fcm_payload['notification']['tag'] = tag + # only add the 'sound' key if sound is not None + # otherwise a default sound will play -- even with empty string args. + if sound: + fcm_payload['notification']['sound'] = sound + + if extra_kwargs: + fcm_payload['notification'].update(extra_kwargs) + + # Do this if you only want to send a data message. + if remove_notification: + del fcm_payload['notification'] + + return self.json_dumps({"message": fcm_payload}) diff --git a/pyfcm/v1/fcm.py b/pyfcm/v1/fcm.py new file mode 100644 index 0000000..e8de567 --- /dev/null +++ b/pyfcm/v1/fcm.py @@ -0,0 +1,6 @@ +from pyfcm import FCMNotification +from pyfcm.v1.baseapi import BaseAPI + +class FCMNotification(FCMNotification, BaseAPI): + def __init__(self, *args, **kwargs): + super(BaseAPI).__init__(*args, **kwargs) diff --git a/tests/test_v1_baseapi.py b/tests/test_v1_baseapi.py new file mode 100644 index 0000000..8484a70 --- /dev/null +++ b/tests/test_v1_baseapi.py @@ -0,0 +1,82 @@ +import os +import json +import pytest + +from pyfcm import errors +from pyfcm.v1.baseapi import BaseAPI + + +@pytest.fixture(scope="module") +def base_api(): + service_account_file_path = "service_account.json" + project_id = os.getenv("FCM_TEST_PROJECT_ID", None) + assert project_id, "Please set the environment variables for testing according to CONTRIBUTING.rst" + + return BaseAPI(service_account_file_path=service_account_file_path, project_id=project_id) + + +def test_init_baseapi(): + try: + BaseAPI() + assert False, "Should raise AuthenticationError" + except errors.AuthenticationError: + pass + + +def test_parse_payload(base_api): + json_string = base_api.parse_payload( + registration_ids=["Test"], + message_body="Test", + message_title="Test", + message_icon="Test", + sound="Test", + collapse_key="Test", + delay_while_idle=False, + time_to_live=0, + restricted_package_name="Test", + low_priority=False, + dry_run=False, + data_message={"test": "test"}, + click_action="Test", + badge="Test", + color="Test", + tag="Test", + body_loc_key="Test", + body_loc_args="Test", + title_loc_key="Test", + title_loc_args="Test", + content_available="Test", + android_channel_id="Test", + timeout=5, + extra_notification_kwargs={}, + extra_kwargs={} + ) + + data = json.loads(json_string.decode("utf-8")) + + assert data["notification"] == { + "android_channel_id": "Test", + "body": "Test", + "click_action": "Test", + "color": "Test", + "icon": "Test", + "sound": "Test", + "tag": "Test", + "title": "Test" + } + + assert 'time_to_live' in data + assert data['time_to_live'] == 0 + + +def test_parse_responses(base_api): + response = base_api.parse_responses() + + assert response == { + "multicast_ids": [], + "success": 0, + "failure": 0, + "canonical_ids": 0, + "results": [], + "topic_message_id": None + } From 3630f459dd3f673977342cd7367540ea7e85bb0e Mon Sep 17 00:00:00 2001 From: Subhransu Das Date: Sat, 1 Jun 2024 04:03:07 +0530 Subject: [PATCH 2/3] Fixed pipeline for test cases --- .github/workflows/python-app.yml | 2 ++ requirements.txt | 8 +++++++- tests/test_v1_baseapi.py | 19 +++++-------------- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 8c262bd..8ab7c51 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -33,4 +33,6 @@ jobs: flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - name: Test with pytest run: | + export FCM_TEST_API_KEY=AAA + export FCM_TEST_PROJECT_ID=test pytest diff --git a/requirements.txt b/requirements.txt index a081b46..0f4a447 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,9 @@ -requests>=2.6.0 aiohttp>=3.6.2 +cachetools==5.3.3 +google-auth==2.29.0 +pyasn1==0.6.0 +pyasn1-modules==0.4.0 +rsa==4.9 +requests>=2.6.0 urllib3==1.26.5 + diff --git a/tests/test_v1_baseapi.py b/tests/test_v1_baseapi.py index 8484a70..c067f42 100644 --- a/tests/test_v1_baseapi.py +++ b/tests/test_v1_baseapi.py @@ -15,14 +15,6 @@ def base_api(): return BaseAPI(service_account_file_path=service_account_file_path, project_id=project_id) -def test_init_baseapi(): - try: - BaseAPI() - assert False, "Should raise AuthenticationError" - except errors.AuthenticationError: - pass - - def test_parse_payload(base_api): json_string = base_api.parse_payload( registration_ids=["Test"], @@ -53,21 +45,20 @@ def test_parse_payload(base_api): ) data = json.loads(json_string.decode("utf-8")) - - assert data["notification"] == { + assert data["message"]["notification"] == { "android_channel_id": "Test", - "body": "Test", + "badge": "Test", "body": "Test", "click_action": "Test", "color": "Test", + "extra_kwargs": {}, + "extra_notification_kwargs": {}, "icon": "Test", "sound": "Test", "tag": "Test", + "timeout": 5, "title": "Test" } - assert 'time_to_live' in data - assert data['time_to_live'] == 0 - def test_parse_responses(base_api): response = base_api.parse_responses() From 686b69a63a4573fe21dda4a9cb43bd10aaf3f970 Mon Sep 17 00:00:00 2001 From: Subhransu Das Date: Sat, 8 Jun 2024 02:50:04 +0530 Subject: [PATCH 3/3] Fixed tests --- .github/workflows/python-app.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 8ab7c51..a1a4cb5 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -35,4 +35,5 @@ jobs: run: | export FCM_TEST_API_KEY=AAA export FCM_TEST_PROJECT_ID=test - pytest + pip install . ".[test]" + python -m pytest .