diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index c902e20..8eed784 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -28,15 +28,34 @@ Some simple guidelines to follow when contributing code: Tests ----- -Before commiting your changes, please run the tests. For running the tests you need a service account. +Before commiting your changes, please run the tests. For running the tests you need service account credentials in a JSON file. +These do NOT have to be real credentials, but must have a properly encoded private key. You can create a key for testing using a site +like `cryptotools `_ . For example: -**Please do not use a service account, which is used in production!** +:: + + { + "type": "service_account", + "project_id": "splendid-donkey-123", + "private_key_id": "12345", + "private_key": "-----BEGIN RSA PRIVATE KEY-----\nMYTESTKEY\n-----END RSA PRIVATE KEY-----", + "client_email": "firebase-adminsdk@splendid-donkey-123.iam.gserviceaccount.com", + "client_id": "789", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-splendid-donkey-123.iam.gserviceaccount.com", + "universe_domain": "googleapis.com" + } + +**Please do not use a service account or private key, which is used in production!** :: pip install . ".[test]" - export GOOGLE_APPLICATION_CREDENTIALS="service_account.json" + export GOOGLE_APPLICATION_CREDENTIALS="path/to/service_account.json" + export FCM_TEST_PROJECT_ID="test-project-id" python -m pytest diff --git a/pyfcm/async_fcm.py b/pyfcm/async_fcm.py index 14c5429..447a6aa 100644 --- a/pyfcm/async_fcm.py +++ b/pyfcm/async_fcm.py @@ -35,7 +35,6 @@ async def send_request(end_point, headers, payload, timeout=5): timeout = aiohttp.ClientTimeout(total=timeout) async with aiohttp.ClientSession(headers=headers, timeout=timeout) as session: - async with session.post(end_point, data=payload) as res: result = await res.text() result = json.loads(result) diff --git a/pyfcm/baseapi.py b/pyfcm/baseapi.py index 7178dd8..5b47f89 100644 --- a/pyfcm/baseapi.py +++ b/pyfcm/baseapi.py @@ -1,7 +1,6 @@ # from __future__ import annotations import json -import os import time import threading @@ -10,12 +9,12 @@ from urllib3 import Retry from google.oauth2 import service_account +from google.oauth2.credentials import Credentials import google.auth.transport.requests from pyfcm.errors import ( AuthenticationError, InvalidDataError, - FCMError, FCMSenderIdMismatchError, FCMServerError, FCMNotRegisteredError, @@ -25,13 +24,13 @@ class BaseAPI(object): - FCM_END_POINT = "https://fcm.googleapis.com/v1/projects" + FCM_END_POINT_BASE = "https://fcm.googleapis.com/v1/projects" def __init__( self, - service_account_file: str, - project_id: str, - credentials=None, + service_account_file: str = None, + project_id: str = None, + credentials: Credentials = None, proxy_dict=None, env=None, json_encoder=None, @@ -48,25 +47,38 @@ def __init__( json_encoder (BaseJSONEncoder): JSON encoder adapter (BaseAdapter): adapter instance """ - self.service_account_file = service_account_file - 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() - self.credentials = credentials - - if not service_account_file and not credentials: + if not (service_account_file or credentials): raise AuthenticationError( "Please provide a service account file path or credentials in the constructor" ) + if credentials is not None: + self.credentials = credentials + else: + self.credentials = service_account.Credentials.from_service_account_file( + service_account_file, + scopes=["https://www.googleapis.com/auth/firebase.messaging"], + ) + + # prefer the project ID scoped to the supplied credentials. + # If, for some reason, the credentials do not specify a project id, + # we'll check for an explicitly supplied one, and raise an error otherwise + project_id = getattr(self.credentials, "project_id", None) or project_id + + if not project_id: + raise AuthenticationError( + "Please provide a project_id either explicitly or through Google credentials." + ) + + self.fcm_end_point = self.FCM_END_POINT_BASE + f"/{project_id}/messages:send" + 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) if env == "app_engine": @@ -101,7 +113,7 @@ def requests_session(self): def send_request(self, payload=None, timeout=None): response = self.requests_session.post( - self.FCM_END_POINT, data=payload, timeout=timeout + self.fcm_end_point, data=payload, timeout=timeout ) if ( "Retry-After" in response.headers @@ -113,14 +125,13 @@ def send_request(self, payload=None, timeout=None): return response def send_async_request(self, params_list, timeout): - import asyncio from .async_fcm import fetch_tasks payloads = [self.parse_payload(**params) for params in params_list] responses = asyncio.new_event_loop().run_until_complete( fetch_tasks( - end_point=self.FCM_END_POINT, + end_point=self.fcm_end_point, headers=self.request_headers(), payloads=payloads, timeout=timeout, @@ -138,16 +149,9 @@ def _get_access_token(self): """ # get OAuth 2.0 access token try: - if self.service_account_file: - credentials = service_account.Credentials.from_service_account_file( - self.service_account_file, - scopes=["https://www.googleapis.com/auth/firebase.messaging"], - ) - else: - credentials = self.credentials request = google.auth.transport.requests.Request() - credentials.refresh(request) - return credentials.token + self.credentials.refresh(request) + return self.credentials.token except Exception as e: raise InvalidDataError(e) @@ -195,7 +199,6 @@ def parse_response(self, response): FCMSenderIdMismatchError: the authenticated sender is different from the sender registered to the token FCMNotRegisteredError: device token is missing, not registered, or invalid """ - if response.status_code == 200: if ( "content-length" in response.headers @@ -283,7 +286,9 @@ def parse_payload( else: raise InvalidDataError("Provided fcm_options is in the wrong format") - fcm_payload["notification"] = ( + fcm_payload[ + "notification" + ] = ( {} ) # - https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#notification # If title is present, use it diff --git a/requirements.txt b/requirements.txt index 51b2945..798d3bd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,5 +7,3 @@ rsa==4.9 requests>=2.6.0 urllib3==1.26.19 pytest-mock==3.14.0 - - diff --git a/tests/test_fcm.py b/tests/test_fcm.py index ecabc93..f923ff3 100644 --- a/tests/test_fcm.py +++ b/tests/test_fcm.py @@ -1,5 +1,7 @@ import pytest from pyfcm import FCMNotification, errors +import os +from google.oauth2 import service_account def test_push_service_without_credentials(): @@ -10,6 +12,22 @@ def test_push_service_without_credentials(): pass +def test_push_service_directly_passed_credentials(): + service_account_file = os.getenv("GOOGLE_APPLICATION_CREDENTIALS", None) + credentials = service_account.Credentials.from_service_account_file( + service_account_file, + scopes=["https://www.googleapis.com/auth/firebase.messaging"], + ) + push_service = FCMNotification(credentials=credentials) + + # We should infer the project ID/endpoint from credentials + # without the need to explcitily pass it + assert push_service.fcm_end_point == ( + "https://fcm.googleapis.com/v1/projects/" + f"{credentials.project_id}/messages:send" + ) + + def test_notify(push_service, generate_response): response = push_service.notify( fcm_token="Test",