Skip to content

Commit

Permalink
Merge pull request #319 from Subhrans/feature-pyfcm-v1
Browse files Browse the repository at this point in the history
Added v1 endpoints for pyFCm in new v1 package.
  • Loading branch information
olucurious committed Jun 7, 2024
2 parents 69c754b + 686b69a commit 51d7d62
Show file tree
Hide file tree
Showing 6 changed files with 290 additions and 2 deletions.
5 changes: 4 additions & 1 deletion .github/workflows/python-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,7 @@ jobs:
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Test with pytest
run: |
pytest
export FCM_TEST_API_KEY=AAA
export FCM_TEST_PROJECT_ID=test
pip install . ".[test]"
python -m pytest .
Empty file added pyfcm/v1/__init__.py
Empty file.
200 changes: 200 additions & 0 deletions pyfcm/v1/baseapi.py
Original file line number Diff line number Diff line change
@@ -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})
6 changes: 6 additions & 0 deletions pyfcm/v1/fcm.py
Original file line number Diff line number Diff line change
@@ -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)
8 changes: 7 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -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

73 changes: 73 additions & 0 deletions tests/test_v1_baseapi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
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_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["message"]["notification"] == {
"android_channel_id": "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"
}


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
}

0 comments on commit 51d7d62

Please sign in to comment.