├── .flake8 ├── tests ├── __init__.py ├── test_errors.py ├── conftest.py ├── test_encoders.py ├── test_utils.py └── test_client.py ├── renovate.json ├── .mypy.ini ├── async_firebase ├── __init__.py ├── _config.py ├── encoders.py ├── errors.py ├── base.py ├── utils.py ├── messages.py └── client.py ├── pytest.ini ├── .coveragerc ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── cd.yml │ ├── codeql-analysis.yml │ └── ci.yml ├── SECURITY.md ├── LICENSE ├── .pre-commit-config.yaml ├── pyproject.toml ├── Makefile ├── .gitignore ├── CODE_OF_CONDUCT.md ├── README.md └── CHANGES.md /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | max-complexity = 10 4 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Package that houses tests for Async Firebase Client""" 2 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json" 3 | } 4 | -------------------------------------------------------------------------------- /.mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | python_version = 3.9 3 | follow_imports = skip 4 | ignore_missing_imports = True 5 | -------------------------------------------------------------------------------- /async_firebase/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from .client import AsyncFirebaseClient # noqa 4 | 5 | 6 | root_logger = logging.getLogger("async_firebase") 7 | if root_logger.level == logging.NOTSET: 8 | root_logger.setLevel(logging.WARN) 9 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --pyargs -ra --no-cov-on-fail --disable-pytest-warnings --cache-clear --cov=async_firebase --cov-config=.coveragerc --cov-branch --cov-report=xml --cov-report=term 3 | norecursedirs = 4 | .eggs 5 | *.egg 6 | .ropeproject 7 | build 8 | testpaths = 9 | tests 10 | flakes-ignore = 11 | *.py E712 12 | filterwarnings = 13 | ignore::DeprecationWarning 14 | ignore::PendingDeprecationWarning 15 | console_output_style = progress 16 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = 3 | ./async_firebase 4 | omit = 5 | # omit tests 6 | */tests/* 7 | */__init__.py 8 | 9 | [report] 10 | # Regexes for lines to exclude from consideration 11 | exclude_lines = 12 | # Don't complain if tests don't hit defensive assertion code: 13 | raise AssertionError 14 | raise NotImplementedError 15 | 16 | pragma: no cover 17 | Should not reach here 18 | def __repr__ 19 | raise NotImplementedError 20 | except ImportError 21 | 22 | # Don't complain if non-runnable code isn't run: 23 | if 0: 24 | if __name__ == .__main__.: 25 | -------------------------------------------------------------------------------- /tests/test_errors.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from async_firebase.errors import ( 4 | DeadlineExceededError, 5 | NotFoundError, 6 | PermissionDeniedError, 7 | ResourceExhaustedError, 8 | UnauthenticatedError, 9 | UnavailableError, 10 | UnknownError, 11 | ) 12 | 13 | 14 | @pytest.mark.parametrize("err_cls", ( 15 | DeadlineExceededError, 16 | NotFoundError, 17 | PermissionDeniedError, 18 | ResourceExhaustedError, 19 | UnauthenticatedError, 20 | UnavailableError, 21 | UnknownError, 22 | )) 23 | def test_create_firebase_error(err_cls): 24 | with pytest.raises(err_cls) as e: 25 | raise err_cls("MESSAGE") 26 | assert e.type is err_cls 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | As of June 3, 2022: 6 | 7 | | Version | Supported | 8 | | ------- | ------------------ | 9 | | 3.x.x | :dart: Planned with breaking changes (drop support of Python 3.6) | 10 | | 2.x.x | :building_construction: Actively developing | 11 | | 1.9.x | :white_check_mark: Current Stable, security updates only | 12 | | < 1.0 | :x: Older versions are not supported | 13 | 14 | ## Reporting a Vulnerability 15 | 16 | Please report (suspected) security vulnerabilities to security@healthjoy.com. 17 | You will receive a response from us within 48 hours. If the issue is confirmed, 18 | we will release a patch as soon as possible depending on complexity. 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 HealthJoy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: '^tests' 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v4.3.0 5 | hooks: 6 | # forgotten debugger imports like pdb 7 | - id: debug-statements 8 | # merge cruft like '<<<<<<< ' 9 | - id: check-merge-conflict 10 | - id: trailing-whitespace 11 | - id: end-of-file-fixer 12 | - id: check-yaml 13 | 14 | - repo: https://github.com/psf/black 15 | rev: 24.3.0 16 | hooks: 17 | - id: black 18 | language_version: python3.9 19 | args: [--line-length=120, --skip-string-normalization] 20 | 21 | - repo: https://github.com/pycqa/flake8 22 | rev: 7.2.0 23 | hooks: 24 | - id: flake8 25 | 26 | - repo: https://github.com/pycqa/isort 27 | rev: 6.0.1 28 | hooks: 29 | - id: isort 30 | stages: [pre-commit] 31 | 32 | - repo: https://github.com/pre-commit/mirrors-mypy 33 | rev: v1.15.0 34 | hooks: 35 | - id: mypy 36 | args: [--no-error-summary, --hide-error-codes, --follow-imports=skip] 37 | files: ^async_firebase/ 38 | additional_dependencies: [types-setuptools] 39 | -------------------------------------------------------------------------------- /async_firebase/_config.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | from dataclasses import dataclass 3 | 4 | 5 | @dataclass() 6 | class RequestTimeout: 7 | """ 8 | Request timeout configuration. 9 | 10 | Arguments: 11 | timeout: Timeout on all operations eg, read, write, connect. 12 | 13 | Examples: 14 | RequestTimeout(None) # No timeouts. 15 | RequestTimeout(5.0) # 5s timeout on all operations. 16 | """ 17 | 18 | timeout: t.Optional[float] = None 19 | 20 | 21 | @dataclass() 22 | class RequestLimits: 23 | """ 24 | Configuration for request limits. 25 | 26 | Attributes: 27 | max_connections: The maximum number of concurrent connections that may be established. 28 | max_keepalive_connections: Allow the connection pool to maintain keep-alive connections 29 | below this point. Should be less than or equal to `max_connections`. 30 | keepalive_expiry: Time limit on idle keep-alive connections in seconds. 31 | """ 32 | 33 | max_connections: t.Optional[int] = None 34 | max_keepalive_connections: t.Optional[int] = None 35 | keepalive_expiry: t.Optional[int] = None 36 | 37 | 38 | DEFAULT_REQUEST_TIMEOUT = RequestTimeout(timeout=5.0) 39 | DEFAULT_REQUEST_LIMITS = RequestLimits(max_connections=100, max_keepalive_connections=20, keepalive_expiry=5) 40 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | import pytest 5 | from cryptography.hazmat.primitives import serialization 6 | from cryptography.hazmat.primitives.asymmetric import rsa 7 | from faker import Faker 8 | 9 | 10 | @pytest.fixture() 11 | def faker_(): 12 | return Faker() 13 | 14 | 15 | @pytest.fixture() 16 | def fake_service_account(faker_): 17 | project_id = f"fake-mobile-app" 18 | client_email = f"firebase-adminsdk-h18o4@{project_id}.iam.gserviceaccount.com" 19 | private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) 20 | pem = private_key.private_bytes( 21 | encoding=serialization.Encoding.PEM, 22 | format=serialization.PrivateFormat.PKCS8, 23 | encryption_algorithm=serialization.NoEncryption(), 24 | ) 25 | return { 26 | "type": "service_account", 27 | "project_id": project_id, 28 | "private_key_id": faker_.uuid4(cast_to=None).hex, 29 | "private_key": pem.decode(), 30 | "client_email": client_email, 31 | "client_id": faker_.bothify(text="#" * 21), 32 | "auth_uri": "https://accounts.google.com/o/oauth2/auth", 33 | "token_uri": "https://oauth2.googleapis.com/token", 34 | "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", 35 | "client_x509_cert_url": f"https://www.googleapis.com/robot/v1/metadata/x509/{client_email}", 36 | } 37 | 38 | 39 | @pytest.fixture() 40 | def fake_service_account_file(fake_service_account, faker_): 41 | file_name = Path(f"fake-mobile-app-{faker_.pystr(min_chars=12, max_chars=18)}.json") 42 | with open(str(file_name), "w") as outfile: 43 | json.dump(fake_service_account, outfile) 44 | yield file_name 45 | file_name.unlink() 46 | -------------------------------------------------------------------------------- /async_firebase/encoders.py: -------------------------------------------------------------------------------- 1 | """The module houses encoders needed to properly form the payload. 2 | 3 | """ 4 | 5 | import typing as t 6 | from copy import deepcopy 7 | 8 | from async_firebase.messages import Aps, ApsAlert 9 | 10 | 11 | def aps_encoder(aps: Aps) -> t.Optional[t.Dict[str, t.Any]]: 12 | """Encode APS instance to JSON so it can be handled by APNS. 13 | 14 | Encode the message according to https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#apnsconfig 15 | :param aps: instance of ``messages.Aps`` class. 16 | :return: APNS compatible payload. 17 | """ 18 | if not aps: 19 | return None 20 | 21 | custom_data: t.Dict[str, t.Any] = deepcopy(aps.custom_data) or {} # type: ignore 22 | 23 | payload = { 24 | "aps": { 25 | "alert": ( 26 | { 27 | "title": aps.alert.title, 28 | "body": aps.alert.body, 29 | "loc-key": aps.alert.loc_key, 30 | "loc-args": aps.alert.loc_args, 31 | "title-loc-key": aps.alert.title_loc_key, 32 | "title-loc-args": aps.alert.title_loc_args, 33 | "action-loc-key": aps.alert.action_loc_key, 34 | "launch-image": aps.alert.launch_image, 35 | } 36 | if isinstance(aps.alert, ApsAlert) 37 | else aps.alert 38 | ), 39 | "badge": aps.badge, 40 | "sound": aps.sound, 41 | "category": aps.category, 42 | "thread-id": aps.thread_id, 43 | "mutable-content": 1 if aps.mutable_content else 0, 44 | }, 45 | **custom_data, 46 | } 47 | 48 | if aps.content_available is True: 49 | payload["aps"]["content-available"] = 1 50 | 51 | return payload 52 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: CD 2 | 3 | on: 4 | push: 5 | branches: 6 | - "master" 7 | tags: 8 | - "v[0-9]+.[0-9]+.[0-9]+" 9 | 10 | # Allows you to run this workflow manually from the Actions tab 11 | workflow_dispatch: 12 | 13 | env: 14 | POETRY_VERSION: 1.5.1 15 | 16 | jobs: 17 | create-virtualenv: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: source code 21 | uses: actions/checkout@v4 22 | 23 | - name: Set up Python 24 | uses: actions/setup-python@v5 25 | 26 | - name: Install Poetry 27 | uses: snok/install-poetry@v1 28 | with: 29 | version: ${{ env.POETRY_VERSION }} 30 | virtualenvs-create: true 31 | virtualenvs-in-project: true 32 | 33 | - name: Load cached venv 34 | id: cached-poetry-dependencies 35 | uses: actions/cache@v3 36 | with: 37 | path: .venv 38 | key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }} 39 | 40 | - name: Install Dependencies 41 | if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' 42 | run: poetry install --no-interaction -vv 43 | 44 | - name: Log currently installed packages and versions 45 | run: poetry show 46 | 47 | release: 48 | needs: create-virtualenv 49 | runs-on: ubuntu-latest 50 | steps: 51 | - name: source code 52 | if: startsWith(github.ref, 'refs/tags/') 53 | uses: actions/checkout@v4 54 | 55 | - name: Set up Python 56 | if: startsWith(github.ref, 'refs/tags/') 57 | uses: actions/setup-python@v5 58 | 59 | - name: Install Poetry 60 | uses: snok/install-poetry@v1 61 | with: 62 | version: ${{ env.POETRY_VERSION }} 63 | virtualenvs-create: true 64 | virtualenvs-in-project: true 65 | 66 | - name: Release to PyPI 67 | if: startsWith(github.ref, 'refs/tags/') 68 | env: 69 | PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} 70 | POETRY_VERSION: ${{ env.POETRY_VERSION }} 71 | run: | 72 | poetry build 73 | poetry config pypi-token.pypi $PYPI_TOKEN 74 | poetry publish 75 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "async-firebase" 3 | version = "4.1.0" 4 | description = "Async Firebase Client - a Python asyncio client to interact with Firebase Cloud Messaging in an easy way." 5 | license = "MIT" 6 | authors = [ 7 | "Oleksandr Omyshev " 8 | ] 9 | maintainers = [ 10 | "Healthjoy Developers ", 11 | "Oleksandr Omyshev " 12 | ] 13 | readme = "README.md" 14 | repository = "https://github.com/healthjoy/async-firebase" 15 | keywords = ["async", "asyncio", "firebase", "fcm", "python3", "push-notifications"] 16 | classifiers = [ 17 | "Development Status :: 5 - Production/Stable", 18 | "Intended Audience :: Developers", 19 | "License :: OSI Approved :: MIT License", 20 | "Natural Language :: English", 21 | "Programming Language :: Python :: 3 :: Only", 22 | "Programming Language :: Python :: 3.9", 23 | "Programming Language :: Python :: 3.10", 24 | "Programming Language :: Python :: 3.11", 25 | "Programming Language :: Python :: 3.12", 26 | "Programming Language :: Python :: 3.13", 27 | ] 28 | 29 | [tool.poetry.dependencies] 30 | python = ">=3.9,<3.14" 31 | 32 | google-auth = "~2.38.0" 33 | httpx = { version = ">=0.28.1,<1.0.0", extras = ["http2"] } 34 | h11 = { version = ">=0.16.0" } 35 | 36 | [tool.poetry.group.dev.dependencies] 37 | pre-commit = "~4.2" 38 | cryptography = { version = "~44.0.2", python = ">3.9.0,<3.9.1 || >3.9.1,<3.14" } 39 | Faker = "~15.3" 40 | 41 | # tests 42 | pytest = "~8.3" 43 | pytest-asyncio = "~0.26" 44 | pytest-benchmark = "~5.1.0" 45 | pytest-cov = "~6.1.1" 46 | pytest-freezegun = "~0.4.2" 47 | pytest-httpx = "~0.35" 48 | black = "^24.3.0" 49 | mypy = "~1.15.0" 50 | 51 | [tool.isort] 52 | atomic = true 53 | known_third_party = [ 54 | "cryptography", "faker", "google", "httpx", "pytest", "pytest_httpx" 55 | ] 56 | sections = [ 57 | "FUTURE", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER" 58 | ] 59 | known_first_party = ["async_firebase", "tests"] 60 | no_lines_before = "STDLIB" 61 | default_section = "FIRSTPARTY" 62 | lines_after_imports = 2 63 | multi_line_output = 3 64 | include_trailing_comma = true 65 | force_grid_wrap = 0 66 | use_parentheses = true 67 | line_length = 120 68 | ensure_newline_before_comments = true 69 | 70 | [build-system] 71 | requires = ["poetry>=1.0"] 72 | build-backend = "poetry.masonry.api" 73 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BOLD := \033[1m 2 | RESET := \033[0m 3 | 4 | .DEFAULT: help 5 | 6 | .PHONY: help 7 | help: 8 | @echo "$(BOLD)CLI$(RESET)" 9 | @echo "" 10 | @echo "$(BOLD)make install$(RESET)" 11 | @echo " install all requirements" 12 | @echo "" 13 | @echo "$(BOLD)make update$(RESET)" 14 | @echo " update all requirements" 15 | @echo "" 16 | @echo "$(BOLD)make setup_dev$(RESET)" 17 | @echo " install all requirements and setup for development" 18 | @echo "" 19 | @echo "$(BOLD)make test$(RESET)" 20 | @echo " run tests" 21 | @echo "" 22 | @echo "$(BOLD)make mypy$(RESET)" 23 | @echo " run static type checker (mypy)" 24 | @echo "" 25 | @echo "$(BOLD)make clean$(RESET)" 26 | @echo " clean trash like *.pyc files" 27 | @echo "" 28 | @echo "$(BOLD)make install_pre_commit$(RESET)" 29 | @echo " install pre_commit hook for git, " 30 | @echo " so that linters will check up code before every commit" 31 | @echo "" 32 | @echo "$(BOLD)make pre_commit$(RESET)" 33 | @echo " run linters check up" 34 | @echo "" 35 | 36 | .PHONY: install 37 | install: 38 | @echo "$(BOLD)Installing package$(RESET)" 39 | @poetry config virtualenvs.create false 40 | @poetry install --only main 41 | @echo "$(BOLD)Done!$(RESET)" 42 | 43 | .PHONY: update 44 | update: 45 | @echo "$(BOLD)Updating package and dependencies$(RESET)" 46 | @poetry update 47 | @echo "$(BOLD)Done!$(RESET)" 48 | 49 | .PHONY: setup_dev 50 | setup_dev: 51 | @echo "$(BOLD)DEV setup$(RESET)" 52 | @poetry install --only dev 53 | @echo "$(BOLD)Done!$(RESET)" 54 | 55 | .PHONY: clean 56 | clean: 57 | @echo "$(BOLD)Cleaning up repository$(RESET)" 58 | @find . -name \*.pyc -delete 59 | @echo "$(BOLD)Done!$(RESET)" 60 | 61 | .PHONY: test 62 | test: setup_dev 63 | @echo "$(BOLD)Running tests$(RESET)" 64 | @poetry run pytest --maxfail=2 ${ARGS} 65 | @echo "$(BOLD)Done!$(RESET)" 66 | 67 | .PHONY: mypy 68 | mypy: setup_dev 69 | @echo "$(BOLD)Running static type checker (mypy)$(RESET)" 70 | @poetry run mypy --no-error-summary --hide-error-codes --follow-imports=skip async_firebase 71 | @echo "$(BOLD)Done!$(RESET)" 72 | 73 | .PHONY: install_pre_commit 74 | install_pre_commit: 75 | @echo "$(BOLD)Add pre-commit hook for git$(RESET)" 76 | @pre-commit install 77 | @echo "$(BOLD)Done!$(RESET)" 78 | 79 | .PHONY: pre_commit 80 | pre_commit: 81 | @echo "$(BOLD)Run pre-commit$(RESET)" 82 | @pre-commit run --all-files 83 | @echo "$(BOLD)Done!$(RESET)" 84 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | .idea 132 | .vscode/ 133 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | paths: 18 | - "**.py" 19 | pull_request: 20 | # The branches below must be a subset of the branches above 21 | branches: [ master ] 22 | paths: 23 | - "**.py" 24 | schedule: 25 | - cron: '32 13 * * 1' 26 | 27 | jobs: 28 | analyze: 29 | name: Analyze 30 | runs-on: ubuntu-latest 31 | permissions: 32 | actions: read 33 | contents: read 34 | security-events: write 35 | 36 | strategy: 37 | fail-fast: false 38 | matrix: 39 | language: [ 'python' ] 40 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 41 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 42 | 43 | steps: 44 | - name: Checkout repository 45 | uses: actions/checkout@v3 46 | 47 | # Initializes the CodeQL tools for scanning. 48 | - name: Initialize CodeQL 49 | uses: github/codeql-action/init@v3 50 | with: 51 | languages: ${{ matrix.language }} 52 | # If you wish to specify custom queries, you can do so here or in a config file. 53 | # By default, queries listed here will override any specified in a config file. 54 | # Prefix the list here with "+" to use these queries and those in the config file. 55 | 56 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 57 | # queries: security-extended,security-and-quality 58 | 59 | 60 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 61 | # If this step fails, then you should remove it and run the build manually (see below) 62 | - name: Autobuild 63 | uses: github/codeql-action/autobuild@v3 64 | 65 | # ℹ️ Command-line programs to run using the OS shell. 66 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 67 | 68 | # If the Autobuild fails above, remove it and uncomment the following three lines. 69 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 70 | 71 | # - run: | 72 | # echo "Run, Build Application using script" 73 | # ./location_of_script_within_repo/buildscript.sh 74 | 75 | - name: Perform CodeQL Analysis 76 | uses: github/codeql-action/analyze@v3 77 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at developers@healthjoy.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /tests/test_encoders.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from async_firebase.encoders import aps_encoder 4 | from async_firebase.messages import Aps, ApsAlert 5 | 6 | 7 | @pytest.mark.parametrize( 8 | "aps_obj, exp_result", 9 | ( 10 | ( 11 | Aps( 12 | alert="push text", 13 | badge=5, 14 | sound="default", 15 | content_available=True, 16 | category="NEW_MESSAGE", 17 | mutable_content=False, 18 | ), 19 | { 20 | "aps": { 21 | "alert": "push text", 22 | "badge": 5, 23 | "sound": "default", 24 | "content-available": 1, 25 | "category": "NEW_MESSAGE", 26 | "thread-id": None, 27 | "mutable-content": 0, 28 | }, 29 | }, 30 | ), 31 | ( 32 | Aps( 33 | alert=ApsAlert( 34 | title="push-title", 35 | body="push-text", 36 | ), 37 | badge=5, 38 | sound="default", 39 | content_available=True, 40 | category="NEW_MESSAGE", 41 | mutable_content=False, 42 | ), 43 | { 44 | "aps": { 45 | "alert": { 46 | "title": "push-title", 47 | "body": "push-text", 48 | "loc-key": None, 49 | "loc-args": [], 50 | "title-loc-key": None, 51 | "title-loc-args": [], 52 | "action-loc-key": None, 53 | "launch-image": None, 54 | }, 55 | "badge": 5, 56 | "sound": "default", 57 | "content-available": 1, 58 | "category": "NEW_MESSAGE", 59 | "thread-id": None, 60 | "mutable-content": 0, 61 | }, 62 | }, 63 | ), 64 | ( 65 | Aps( 66 | alert="push text", 67 | badge=5, 68 | sound="default", 69 | content_available=True, 70 | category="NEW_MESSAGE", 71 | mutable_content=False, 72 | custom_data={ 73 | "str_attr": "value_1", 74 | "int_attr": 42, 75 | "float_attr": 42.42, 76 | "list_attr": [1, 2, 3], 77 | "dict_attr": {"a": "A", "b": "B"}, 78 | "bool_attr": False, 79 | }, 80 | ), 81 | { 82 | "aps": { 83 | "alert": "push text", 84 | "badge": 5, 85 | "sound": "default", 86 | "content-available": 1, 87 | "category": "NEW_MESSAGE", 88 | "thread-id": None, 89 | "mutable-content": 0, 90 | }, 91 | "str_attr": "value_1", 92 | "int_attr": 42, 93 | "float_attr": 42.42, 94 | "list_attr": [1, 2, 3], 95 | "dict_attr": {"a": "A", "b": "B"}, 96 | "bool_attr": False, 97 | }, 98 | ), 99 | (None, None), 100 | ), 101 | ) 102 | def test_aps_encoder(aps_obj, exp_result): 103 | aps_dict = aps_encoder(aps_obj) 104 | assert aps_dict == exp_result 105 | 106 | def test_aps_encoder_does_not_modify_custom_data(): 107 | custom_data = { 108 | "str_attr": "value_1", 109 | "int_attr": 42, 110 | "float_attr": 42.42, 111 | "list_attr": [1, 2, 3], 112 | "dict_attr": {"a": "A", "b": "B"}, 113 | "bool_attr": False, 114 | } 115 | 116 | aps = Aps( 117 | alert="push text", 118 | badge=5, 119 | sound="default", 120 | content_available=True, 121 | category="NEW_MESSAGE", 122 | mutable_content=False, 123 | custom_data=custom_data.copy(), 124 | ) 125 | 126 | assert aps.custom_data == custom_data 127 | aps_encoder(aps) 128 | assert len(custom_data) == 6 129 | assert aps.custom_data == custom_data 130 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | # Controls when the action will run. 4 | on: 5 | # Triggers the workflow on push or pull request events but only for the master branch 6 | push: 7 | branches: 8 | - "master" 9 | - "ci" 10 | - "v[0-9]+.[0-9]+.[0-9]+" 11 | - "v[0-9]+.[0-9]+.x" 12 | paths: 13 | - "**.py" 14 | pull_request: 15 | branches: 16 | - "master" 17 | 18 | # Allows you to run this workflow manually from the Actions tab 19 | workflow_dispatch: 20 | 21 | env: 22 | POETRY_VERSION: 1.5.1 23 | 24 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 25 | jobs: 26 | create-virtualenv: 27 | runs-on: ubuntu-latest 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | python-version: [ '3.9', '3.10', '3.11', '3.12', '3.13' ] 32 | steps: 33 | - name: Checkout source code 34 | uses: actions/checkout@v4 35 | 36 | - name: Set up Python ${{ matrix.python-version }} 37 | id: setup-python 38 | uses: actions/setup-python@v5 39 | with: 40 | python-version: ${{ matrix.python-version }} 41 | 42 | - name: Install Poetry 43 | uses: snok/install-poetry@v1 44 | with: 45 | version: ${{ env.POETRY_VERSION }} 46 | virtualenvs-create: true 47 | virtualenvs-in-project: true 48 | 49 | - name: Load cached venv 50 | id: cached-poetry-dependencies 51 | uses: actions/cache@v3 52 | with: 53 | path: .venv 54 | key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }} 55 | 56 | - name: Install Dependencies 57 | if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' 58 | run: poetry install --no-interaction --no-root 59 | 60 | - name: Install library 61 | run: poetry install --no-interaction 62 | 63 | - name: Log currently installed packages and versions 64 | run: poetry show 65 | 66 | linters-black: 67 | needs: create-virtualenv 68 | runs-on: ubuntu-latest 69 | steps: 70 | - name: source code 71 | uses: actions/checkout@v4 72 | 73 | - name: Set up Python 74 | uses: actions/setup-python@v5 75 | with: 76 | python-version: ${{ matrix.python-version }} 77 | 78 | - name: Install Poetry 79 | uses: snok/install-poetry@v1 80 | with: 81 | version: ${{ env.POETRY_VERSION }} 82 | virtualenvs-in-project: true 83 | installer-parallel: true 84 | 85 | - name: Load cached venv 86 | id: cached-poetry-dependencies 87 | uses: actions/cache@v3 88 | with: 89 | path: .venv 90 | key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }} 91 | 92 | - name: Install dependencies 93 | if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' 94 | run: poetry install --no-interaction --no-root 95 | 96 | - name: Install library 97 | run: poetry install --no-interaction 98 | 99 | - name: Check code style 100 | run: poetry run black --check --line-length=120 --diff async_firebase 101 | 102 | linters-mypy: 103 | needs: create-virtualenv 104 | runs-on: ubuntu-latest 105 | steps: 106 | - name: source code 107 | uses: actions/checkout@v4 108 | 109 | - name: Set up Python 110 | uses: actions/setup-python@v5 111 | with: 112 | python-version: ${{ matrix.python-version }} 113 | 114 | - name: Install Poetry 115 | uses: snok/install-poetry@v1 116 | with: 117 | version: ${{ env.POETRY_VERSION }} 118 | virtualenvs-create: true 119 | virtualenvs-in-project: true 120 | 121 | - name: Load cached venv 122 | id: cached-poetry-dependencies 123 | uses: actions/cache@v3 124 | with: 125 | path: .venv 126 | key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }} 127 | 128 | - name: Install dependencies 129 | if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' 130 | run: poetry install --no-interaction --no-root 131 | 132 | - name: Install library 133 | run: poetry install --no-interaction 134 | 135 | - name: Static type checker 136 | run: | 137 | python -m pip install types-setuptools 138 | poetry run mypy --install-types --no-error-summary --hide-error-codes --follow-imports=skip async_firebase 139 | 140 | test: 141 | needs: [ linters-black, linters-mypy ] 142 | runs-on: ubuntu-latest 143 | strategy: 144 | matrix: 145 | python-version: [ '3.9', '3.10', '3.11', '3.12', '3.13' ] 146 | steps: 147 | - name: source code 148 | uses: actions/checkout@v4 149 | 150 | - name: Set up Python ${{ matrix.python-version }} 151 | id: setup-python 152 | uses: actions/setup-python@v5 153 | with: 154 | python-version: ${{ matrix.python-version }} 155 | 156 | - name: Install Poetry 157 | uses: snok/install-poetry@v1 158 | with: 159 | version: ${{ env.POETRY_VERSION }} 160 | virtualenvs-create: true 161 | virtualenvs-in-project: true 162 | 163 | - name: Load cached venv 164 | id: cached-poetry-dependencies 165 | uses: actions/cache@v3 166 | with: 167 | path: .venv 168 | key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }} 169 | 170 | - name: Install dependencies 171 | if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' 172 | run: poetry install --no-interaction --no-root 173 | 174 | - name: Install library 175 | run: poetry install --no-interaction 176 | 177 | - name: Run pytest 178 | run: | 179 | poetry run pip install --upgrade setuptools 180 | poetry run pytest tests/ 181 | 182 | - name: Submit coverage report 183 | if: github.ref == 'refs/heads/master' 184 | env: 185 | CODACY_PROJECT_TOKEN: ${{ secrets.CODACY_TOKEN }} 186 | run: | 187 | pip install codacy-coverage 188 | python-codacy-coverage -r coverage.xml 189 | -------------------------------------------------------------------------------- /async_firebase/errors.py: -------------------------------------------------------------------------------- 1 | """Async Firebase errors.""" 2 | 3 | import typing as t 4 | from enum import Enum 5 | 6 | import httpx 7 | 8 | 9 | class FcmErrorCode(Enum): 10 | INVALID_ARGUMENT = "INVALID_ARGUMENT" 11 | FAILED_PRECONDITION = "FAILED_PRECONDITION" 12 | OUT_OF_RANGE = "OUT_OF_RANGE" 13 | UNAUTHENTICATED = "UNAUTHENTICATED" 14 | PERMISSION_DENIED = "PERMISSION_DENIED" 15 | NOT_FOUND = "NOT_FOUND" 16 | CONFLICT = "CONFLICT" 17 | ABORTED = "ABORTED" 18 | ALREADY_EXISTS = "ALREADY_EXISTS" 19 | RESOURCE_EXHAUSTED = "RESOURCE_EXHAUSTED" 20 | CANCELLED = "CANCELLED" 21 | DATA_LOSS = "DATA_LOSS" 22 | UNKNOWN = "UNKNOWN" 23 | INTERNAL = "INTERNAL" 24 | UNAVAILABLE = "UNAVAILABLE" 25 | DEADLINE_EXCEEDED = "DEADLINE_EXCEEDED" 26 | 27 | 28 | class BaseAsyncFirebaseError(Exception): 29 | """Base error for Async Firebase""" 30 | 31 | 32 | class AsyncFirebaseError(BaseAsyncFirebaseError): 33 | """A prototype for all AF Errors. 34 | 35 | This error and its subtypes and the reason to rise them are consistent with Google's errors, 36 | that may be found in `firebase-admin-python` in `firebase_admin.exceptions module`. 37 | """ 38 | 39 | def __init__( 40 | self, 41 | code: str, 42 | message: str, 43 | cause: t.Optional[httpx.HTTPError] = None, 44 | http_response: t.Optional[httpx.Response] = None, 45 | ): 46 | """Init the AsyncFirebase error. 47 | 48 | :param code: A string error code that represents the type of the exception. Possible error 49 | codes are defined in https://cloud.google.com/apis/design/errors#handling_errors. 50 | :param message: A human-readable error message string. 51 | :param cause: The exception that caused this error (optional). 52 | :param http_response: If this error was caused by an HTTP error response, this property is 53 | set to the ``httpx.Response`` object that represents the HTTP response (optional). 54 | See https://www.python-httpx.org/api/#response for details of this object. 55 | """ 56 | self.code = code 57 | self.cause = cause 58 | self.http_response = http_response 59 | super().__init__(message) 60 | 61 | 62 | class DeadlineExceededError(AsyncFirebaseError): 63 | """Request deadline exceeded. 64 | 65 | This will happen only if the caller sets a deadline that is shorter than the method's 66 | default deadline (i.e. requested deadline is not enough for the server to process the 67 | request) and the request did not finish within the deadline. 68 | """ 69 | 70 | def __init__(self, message, cause=None, http_response=None): 71 | """Please see params information in the base exception docstring.""" 72 | super().__init__(FcmErrorCode.DEADLINE_EXCEEDED.value, message, cause=cause, http_response=http_response) 73 | 74 | 75 | class UnavailableError(AsyncFirebaseError): 76 | """Service unavailable. Typically the server is down.""" 77 | 78 | def __init__(self, message, cause=None, http_response=None): 79 | """Please see params information in the base exception docstring.""" 80 | super().__init__(FcmErrorCode.UNAVAILABLE.value, message, cause=cause, http_response=http_response) 81 | 82 | 83 | class UnknownError(AsyncFirebaseError): 84 | """Unknown server error.""" 85 | 86 | def __init__(self, message, cause=None, http_response=None): 87 | """Please see params information in the base exception docstring.""" 88 | super().__init__(FcmErrorCode.UNKNOWN.value, message, cause=cause, http_response=http_response) 89 | 90 | 91 | class UnauthenticatedError(AsyncFirebaseError): 92 | """Request not authenticated due to missing, invalid, or expired OAuth token.""" 93 | 94 | def __init__(self, message, cause=None, http_response=None): 95 | """Please see params information in the base exception docstring.""" 96 | super().__init__(FcmErrorCode.UNAUTHENTICATED.value, message, cause=cause, http_response=http_response) 97 | 98 | 99 | class ThirdPartyAuthError(UnauthenticatedError): 100 | """APNs certificate or web push auth key was invalid or missing.""" 101 | 102 | 103 | class ResourceExhaustedError(AsyncFirebaseError): 104 | """Either out of resource quota or reaching rate limiting.""" 105 | 106 | def __init__(self, message, cause=None, http_response=None): 107 | """Please see params information in the base exception docstring.""" 108 | super().__init__(FcmErrorCode.RESOURCE_EXHAUSTED.value, message, cause=cause, http_response=http_response) 109 | 110 | 111 | class QuotaExceededError(ResourceExhaustedError): 112 | """Sending limit exceeded for the message target.""" 113 | 114 | 115 | class PermissionDeniedError(AsyncFirebaseError): 116 | """Client does not have sufficient permission. 117 | 118 | This can happen because the OAuth token does not have the right scopes, the client doesn't 119 | have permission, or the API has not been enabled for the client project. 120 | """ 121 | 122 | def __init__(self, message, cause=None, http_response=None): 123 | """Please see params information in the base exception docstring.""" 124 | super().__init__(FcmErrorCode.PERMISSION_DENIED.value, message, cause=cause, http_response=http_response) 125 | 126 | 127 | class SenderIdMismatchError(PermissionDeniedError): 128 | """The authenticated sender ID is different from the sender ID for the registration token.""" 129 | 130 | 131 | class NotFoundError(AsyncFirebaseError): 132 | """A specified resource is not found, or the request is rejected by undisclosed reasons. 133 | 134 | An example of the possible cause of this error is whitelisting. 135 | """ 136 | 137 | def __init__(self, message, cause=None, http_response=None): 138 | """Please see params information in the base exception docstring.""" 139 | super().__init__(FcmErrorCode.NOT_FOUND.value, message, cause=cause, http_response=http_response) 140 | 141 | 142 | class UnregisteredError(NotFoundError): 143 | """App instance was unregistered from FCM. 144 | 145 | This usually means that the token used is no longer valid and a new one must be used. 146 | """ 147 | 148 | 149 | class InvalidArgumentError(AsyncFirebaseError): 150 | """Client specified an invalid argument.""" 151 | 152 | def __init__(self, message, cause=None, http_response=None): 153 | super().__init__(FcmErrorCode.INVALID_ARGUMENT.value, message, cause=cause, http_response=http_response) 154 | 155 | 156 | class FailedPreconditionError(AsyncFirebaseError): 157 | """Request can not be executed in the current system state, such as deleting a non-empty directory.""" 158 | 159 | def __init__(self, message, cause=None, http_response=None): 160 | super().__init__(FcmErrorCode.FAILED_PRECONDITION.value, message, cause=cause, http_response=http_response) 161 | 162 | 163 | class OutOfRangeError(AsyncFirebaseError): 164 | """Client specified an invalid range.""" 165 | 166 | def __init__(self, message, cause=None, http_response=None): 167 | super().__init__(FcmErrorCode.OUT_OF_RANGE.value, message, cause=cause, http_response=http_response) 168 | 169 | 170 | class AbortedError(AsyncFirebaseError): 171 | """Concurrency conflict, such as read-modify-write conflict.""" 172 | 173 | def __init__(self, message, cause=None, http_response=None): 174 | super().__init__(FcmErrorCode.ABORTED.value, message, cause=cause, http_response=http_response) 175 | 176 | 177 | class AlreadyExistsError(AsyncFirebaseError): 178 | """The resource that a client tried to create already exists.""" 179 | 180 | def __init__(self, message, cause=None, http_response=None): 181 | super().__init__(FcmErrorCode.ALREADY_EXISTS.value, message, cause=cause, http_response=http_response) 182 | 183 | 184 | class ConflictError(AsyncFirebaseError): 185 | """Concurrency conflict, such as read-modify-write conflict.""" 186 | 187 | def __init__(self, message, cause=None, http_response=None): 188 | super().__init__(FcmErrorCode.CONFLICT.value, message, cause=cause, http_response=http_response) 189 | 190 | 191 | class CancelledError(AsyncFirebaseError): 192 | """Request cancelled by the client.""" 193 | 194 | def __init__(self, message, cause=None, http_response=None): 195 | super().__init__(FcmErrorCode.CANCELLED.value, message, cause=cause, http_response=http_response) 196 | 197 | 198 | class DataLossError(AsyncFirebaseError): 199 | """Unrecoverable data loss or data corruption.""" 200 | 201 | def __init__(self, message, cause=None, http_response=None): 202 | super().__init__(FcmErrorCode.DATA_LOSS.value, message, cause=cause, http_response=http_response) 203 | 204 | 205 | class InternalError(AsyncFirebaseError): 206 | """Internal server error.""" 207 | 208 | def __init__(self, message, cause=None, http_response=None): 209 | super().__init__(FcmErrorCode.INTERNAL.value, message, cause=cause, http_response=http_response) 210 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from async_firebase.messages import ( 4 | AndroidConfig, 5 | AndroidNotification, 6 | APNSConfig, 7 | APNSPayload, 8 | Aps, 9 | ApsAlert, 10 | Message, 11 | Notification, 12 | PushNotification, 13 | ) 14 | from async_firebase.utils import ( 15 | cleanup_firebase_message, 16 | join_url, 17 | remove_null_values, 18 | ) 19 | 20 | pytestmark = pytest.mark.asyncio 21 | 22 | 23 | @pytest.mark.parametrize( 24 | "data, exp_result", 25 | ( 26 | ( 27 | { 28 | "android": {}, 29 | "apns": {}, 30 | "condition": None, 31 | "data": {}, 32 | "notification": {}, 33 | "token": None, 34 | "topic": None, 35 | "webpush": None, 36 | }, 37 | {}, 38 | ), 39 | ({"key_1": None, "key_2": "value_2", "key_3": []}, {"key_2": "value_2"}), 40 | ( 41 | { 42 | "falsy_string": "", 43 | "falsy_int": 0, 44 | "falsy_bool": False, 45 | "falsy_float": 0.0, 46 | "falsy_dict": {}, 47 | "falsy_list": [], 48 | }, 49 | { 50 | "falsy_string": "", 51 | "falsy_int": 0, 52 | "falsy_bool": False, 53 | "falsy_float": 0.0, 54 | }, 55 | ), 56 | ({}, {}), 57 | ( 58 | { 59 | "key_1": { 60 | "sub_key_1": {}, 61 | "sub_key_2": None, 62 | "sub_key_3": [], 63 | }, 64 | "key_2": None, 65 | }, 66 | {"key_1": {"sub_key_1": {}, "sub_key_2": None, "sub_key_3": []}}, 67 | ), 68 | ), 69 | ) 70 | def test_remove_null_values(data, exp_result): 71 | result = remove_null_values(data) 72 | assert result == exp_result 73 | 74 | 75 | @pytest.mark.parametrize( 76 | "firebase_message, exp_result", 77 | ( 78 | ( 79 | AndroidNotification(title="push-title", body="push-body"), 80 | {"title": "push-title", "body": "push-body"}, 81 | ), 82 | ( 83 | AndroidConfig(collapse_key="group", priority="normal", ttl="3600s"), 84 | {"collapse_key": "group", "priority": "normal", "ttl": "3600s"}, 85 | ), 86 | ( 87 | ApsAlert(title="push-title", body="push-body"), 88 | {"title": "push-title", "body": "push-body"}, 89 | ), 90 | (Aps(alert="alert", badge=9), {"alert": "alert", "badge": 9}), 91 | ( 92 | APNSPayload(aps=Aps(alert="push-text", custom_data={"foo": "bar"})), 93 | {"aps": {"alert": "push-text", "custom_data": {"foo": "bar"}}}, 94 | ), 95 | ( 96 | APNSConfig(headers={"x-header": "x-data"}), 97 | {"headers": {"x-header": "x-data"}}, 98 | ), 99 | ( 100 | Notification(title="push-title", body="push-body"), 101 | {"title": "push-title", "body": "push-body"}, 102 | ), 103 | ( 104 | Notification(title="push-title", body="push-body", image="https://cdn.domain.com/public.image.png"), 105 | {"title": "push-title", "body": "push-body", "image": "https://cdn.domain.com/public.image.png"}, 106 | ), 107 | ( 108 | Message( 109 | token="qwerty", 110 | notification=Notification(title="push-title", body="push-body"), 111 | apns=APNSConfig( 112 | headers={"hdr": "qwe"}, 113 | payload=APNSPayload( 114 | aps=Aps( 115 | sound="generic", 116 | ), 117 | ), 118 | ), 119 | ), 120 | { 121 | "token": "qwerty", 122 | "notification": {"title": "push-title", "body": "push-body"}, 123 | "apns": { 124 | "headers": {"hdr": "qwe"}, 125 | "payload": {"aps": {"sound": "generic"}}, 126 | }, 127 | }, 128 | ), 129 | ( 130 | PushNotification( 131 | message=Message( 132 | token="secret-token", 133 | notification=Notification(title="push-title", body="push-body"), 134 | android=AndroidConfig( 135 | collapse_key="group", 136 | notification=AndroidNotification(title="android-push-title", body="android-push-body"), 137 | ), 138 | ) 139 | ), 140 | { 141 | "message": { 142 | "token": "secret-token", 143 | "notification": {"title": "push-title", "body": "push-body"}, 144 | "android": { 145 | "collapse_key": "group", 146 | "notification": { 147 | "title": "android-push-title", 148 | "body": "android-push-body", 149 | }, 150 | }, 151 | }, 152 | "validate_only": False, 153 | }, 154 | ), 155 | ( 156 | PushNotification( 157 | message=Message( 158 | token="secret-token", 159 | android=AndroidConfig( 160 | collapse_key="group", 161 | notification=AndroidNotification(title="android-push-title", body="android-push-body"), 162 | ), 163 | apns=APNSConfig( 164 | headers={ 165 | "apns-expiration": "1621594859", 166 | "apns-priority": "5", 167 | "apns-collapse-id": "ENTITY_UPDATED", 168 | }, 169 | payload={ 170 | "aps": { 171 | "alert": "push-text", 172 | "badge": 5, 173 | "sound": "default", 174 | "content-available": True, 175 | "category": "NEW_MESSAGE", 176 | "mutable-content": False, 177 | }, 178 | "custom_attr_1": "value_1", 179 | "custom_attr_2": 42, 180 | }, 181 | ), 182 | ) 183 | ), 184 | { 185 | "message": { 186 | "token": "secret-token", 187 | "android": { 188 | "collapse_key": "group", 189 | "notification": { 190 | "title": "android-push-title", 191 | "body": "android-push-body", 192 | }, 193 | }, 194 | "apns": { 195 | "headers": { 196 | "apns-expiration": "1621594859", 197 | "apns-priority": "5", 198 | "apns-collapse-id": "ENTITY_UPDATED", 199 | }, 200 | "payload": { 201 | "aps": { 202 | "alert": "push-text", 203 | "badge": 5, 204 | "sound": "default", 205 | "content-available": True, 206 | "category": "NEW_MESSAGE", 207 | "mutable-content": False, 208 | }, 209 | "custom_attr_1": "value_1", 210 | "custom_attr_2": 42, 211 | }, 212 | }, 213 | }, 214 | "validate_only": False, 215 | }, 216 | ), 217 | ), 218 | ) 219 | def test_cleanup_firebase_message(firebase_message, exp_result): 220 | result = cleanup_firebase_message(firebase_message) 221 | assert result == exp_result 222 | 223 | 224 | @pytest.mark.parametrize( 225 | "base, parts, params, leading_slash, trailing_slash, exp_result", 226 | ( 227 | ("http://base.ai", ["a", "b"], None, False, False, "http://base.ai/a/b"), 228 | ("http://base.ai", ["foo", 42], None, False, False, "http://base.ai/foo/42"), 229 | ( 230 | "http://base.ai", 231 | ["foo", "bar"], 232 | {"q": "test"}, 233 | False, 234 | False, 235 | "http://base.ai/foo/bar?q=test", 236 | ), 237 | ( 238 | "base_path/path_1", 239 | ["foo", "bar"], 240 | None, 241 | True, 242 | False, 243 | "/base_path/path_1/foo/bar", 244 | ), 245 | ( 246 | "http://base.ai", 247 | ["foo", "bar"], 248 | None, 249 | False, 250 | True, 251 | "http://base.ai/foo/bar/", 252 | ), 253 | ( 254 | "base_path/path_1", 255 | ["foo", 42], 256 | {"q": "test"}, 257 | True, 258 | True, 259 | "/base_path/path_1/foo/42/?q=test", 260 | ), 261 | ( 262 | "base_path/path_1", 263 | [], 264 | {"q": "test"}, 265 | True, 266 | False, 267 | "/base_path/path_1?q=test", 268 | ), 269 | ( 270 | "http://base", 271 | ["message:send"], 272 | None, 273 | False, 274 | False, 275 | "http://base/message:send", 276 | ), 277 | ( 278 | "https://fcm.googleapis.com", 279 | ["/v1/projects/my-project", "messages:send"], 280 | None, 281 | False, 282 | False, 283 | "https://fcm.googleapis.com/v1/projects/my-project/messages:send", 284 | ), 285 | ), 286 | ) 287 | def test_join_url_common_flows(base, parts, params, leading_slash, trailing_slash, exp_result): 288 | result = join_url(base, *parts, params=params, leading_slash=leading_slash, trailing_slash=trailing_slash) 289 | assert result == exp_result 290 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # async-firebase is a lightweight asynchronous client to interact with Firebase Cloud Messaging for sending push notification to Android and iOS devices 2 | 3 | [![PyPI download month](https://img.shields.io/pypi/dm/async-firebase.svg)](https://pypi.python.org/pypi/async-firebase/) 4 | [![PyPI version fury.io](https://badge.fury.io/py/async-firebase.svg)](https://pypi.python.org/pypi/async-firebase/) 5 | [![PyPI license](https://img.shields.io/pypi/l/async-firebase.svg)](https://pypi.python.org/pypi/async-firebase/) 6 | [![PyPI pyversions](https://img.shields.io/pypi/pyversions/async-firebase.svg)](https://pypi.python.org/pypi/async-firebase/) 7 | [![CI](https://github.com/healthjoy/async-firebase/actions/workflows/ci.yml/badge.svg)](https://github.com/healthjoy/async-firebase/actions/workflows/ci.yml) 8 | [![Codacy coverage](https://img.shields.io/codacy/coverage/b6a59cdf5ca64eab9104928d4f9bbb97?logo=codacy)](https://app.codacy.com/gh/healthjoy/async-firebase/dashboard) 9 | 10 | 11 | * Free software: MIT license 12 | * Requires: Python 3.9+ 13 | 14 | ## Features 15 | 16 | * Extremely lightweight and does not rely on ``firebase-admin`` which is hefty 17 | * Send push notifications to Android and iOS devices 18 | * Send Multicast push notification to Android and iOS devices 19 | * Send Web push notifications 20 | * Set TTL (time to live) for notifications 21 | * Set priority for notifications 22 | * Set collapse-key for notifications 23 | * Dry-run mode for testing purpose 24 | * Topic Management 25 | 26 | ## Installation 27 | To install `async-firebase`, simply execute the following command in a terminal: 28 | ```shell script 29 | $ pip install async-firebase 30 | ``` 31 | 32 | ## Getting started 33 | ### async-firebase < 3.0.0 34 | To send push notification to Android: 35 | ```python3 36 | import asyncio 37 | 38 | from async_firebase import AsyncFirebaseClient 39 | 40 | 41 | async def main(): 42 | client = AsyncFirebaseClient() 43 | client.creds_from_service_account_file("secret-store/mobile-app-79225efac4bb.json") 44 | 45 | # or using dictionary object 46 | # client.creds_from_service_account_info({...}}) 47 | 48 | device_token = "..." 49 | 50 | android_config = client.build_android_config( 51 | priority="high", 52 | ttl=2419200, 53 | collapse_key="push", 54 | data={"discount": "15%", "key_1": "value_1", "timestamp": "2021-02-24T12:00:15"}, 55 | title="Store Changes", 56 | body="Recent store changes", 57 | ) 58 | response = await client.push(device_token=device_token, android=android_config) 59 | 60 | print(response.success, response.message_id) 61 | 62 | if __name__ == "__main__": 63 | asyncio.run(main()) 64 | ``` 65 | 66 | To send push notification to iOS: 67 | 68 | ```python3 69 | import asyncio 70 | 71 | from async_firebase import AsyncFirebaseClient 72 | 73 | 74 | async def main(): 75 | client = AsyncFirebaseClient() 76 | client.creds_from_service_account_file("secret-store/mobile-app-79225efac4bb.json") 77 | 78 | # or using dictionary object 79 | # client.creds_from_service_account_info({...}}) 80 | 81 | device_token = "..." 82 | 83 | apns_config = client.build_apns_config( 84 | priority="normal", 85 | ttl=2419200, 86 | apns_topic="store-updated", 87 | collapse_key="push", 88 | title="Store Changes", 89 | alert="Recent store changes", 90 | badge=1, 91 | category="test-category", 92 | custom_data={"discount": "15%", "key_1": "value_1", "timestamp": "2021-02-24T12:00:15"} 93 | ) 94 | response = await client.push(device_token=device_token, apns=apns_config) 95 | 96 | print(response.success) 97 | 98 | if __name__ == "__main__": 99 | asyncio.run(main()) 100 | ``` 101 | 102 | This prints: 103 | 104 | ```shell script 105 | "projects/mobile-app/messages/0:2367799010922733%7606eb557606ebff" 106 | ``` 107 | 108 | To manual construct message: 109 | ```python3 110 | import asyncio 111 | from datetime import datetime 112 | 113 | from async_firebase.messages import APNSConfig, APNSPayload, ApsAlert, Aps 114 | from async_firebase import AsyncFirebaseClient 115 | 116 | 117 | async def main(): 118 | apns_config = APNSConfig(**{ 119 | "headers": { 120 | "apns-expiration": str(int(datetime.utcnow().timestamp()) + 7200), 121 | "apns-priority": "10", 122 | "apns-topic": "test-topic", 123 | "apns-collapse-id": "something", 124 | }, 125 | "payload": APNSPayload(**{ 126 | "aps": Aps(**{ 127 | "alert": ApsAlert(title="some-title", body="alert-message"), 128 | "badge": 0, 129 | "sound": "default", 130 | "content_available": True, 131 | "category": "some-category", 132 | "mutable_content": False, 133 | "custom_data": { 134 | "link": "https://link-to-somewhere.com", 135 | "ticket_id": "YXZ-655512", 136 | }, 137 | }) 138 | }) 139 | }) 140 | 141 | device_token = "..." 142 | 143 | client = AsyncFirebaseClient() 144 | client.creds_from_service_account_info({...}) 145 | response = await client.push(device_token=device_token, apns=apns_config) 146 | print(response.success) 147 | 148 | 149 | if __name__ == "__main__": 150 | asyncio.run(main()) 151 | ``` 152 | 153 | ### async-firebase >= 3.0.0 154 | To send push notification to Android: 155 | ```python3 156 | import asyncio 157 | 158 | from async_firebase import AsyncFirebaseClient 159 | from async_firebase.messages import Message 160 | 161 | 162 | async def main(): 163 | client = AsyncFirebaseClient() 164 | client.creds_from_service_account_file("secret-store/mobile-app-79225efac4bb.json") 165 | 166 | # or using dictionary object 167 | # client.creds_from_service_account_info({...}}) 168 | 169 | device_token: str = "..." 170 | 171 | android_config = client.build_android_config( 172 | priority="high", 173 | ttl=2419200, 174 | collapse_key="push", 175 | data={"discount": "15%", "key_1": "value_1", "timestamp": "2021-02-24T12:00:15"}, 176 | title="Store Changes", 177 | body="Recent store changes", 178 | ) 179 | message = Message(android=android_config, token=device_token) 180 | response = await client.send(message) 181 | 182 | print(response.success, response.message_id) 183 | 184 | if __name__ == "__main__": 185 | asyncio.run(main()) 186 | ``` 187 | 188 | To send push notification to iOS: 189 | 190 | ```python3 191 | import asyncio 192 | 193 | from async_firebase import AsyncFirebaseClient 194 | from async_firebase.messages import Message 195 | 196 | 197 | async def main(): 198 | client = AsyncFirebaseClient() 199 | client.creds_from_service_account_file("secret-store/mobile-app-79225efac4bb.json") 200 | 201 | # or using dictionary object 202 | # client.creds_from_service_account_info({...}}) 203 | 204 | device_token: str = "..." 205 | 206 | apns_config = client.build_apns_config( 207 | priority="normal", 208 | ttl=2419200, 209 | apns_topic="store-updated", 210 | collapse_key="push", 211 | title="Store Changes", 212 | alert="Recent store changes", 213 | badge=1, 214 | category="test-category", 215 | custom_data={"discount": "15%", "key_1": "value_1", "timestamp": "2021-02-24T12:00:15"} 216 | ) 217 | message = Message(apns=apns_config, token=device_token) 218 | response = await client.send(message) 219 | 220 | print(response.success) 221 | 222 | if __name__ == "__main__": 223 | asyncio.run(main()) 224 | ``` 225 | 226 | This prints: 227 | 228 | ```shell script 229 | "projects/mobile-app/messages/0:2367799010922733%7606eb557606ebff" 230 | ``` 231 | 232 | To manual construct message: 233 | ```python3 234 | import asyncio 235 | from datetime import datetime 236 | 237 | from async_firebase.messages import APNSConfig, APNSPayload, ApsAlert, Aps, Message 238 | from async_firebase import AsyncFirebaseClient 239 | 240 | 241 | async def main(): 242 | apns_config = APNSConfig(**{ 243 | "headers": { 244 | "apns-expiration": str(int(datetime.utcnow().timestamp()) + 7200), 245 | "apns-priority": "10", 246 | "apns-topic": "test-topic", 247 | "apns-collapse-id": "something", 248 | }, 249 | "payload": APNSPayload(**{ 250 | "aps": Aps(**{ 251 | "alert": ApsAlert(title="some-title", body="alert-message"), 252 | "badge": 0, 253 | "sound": "default", 254 | "content_available": True, 255 | "category": "some-category", 256 | "mutable_content": False, 257 | "custom_data": { 258 | "link": "https://link-to-somewhere.com", 259 | "ticket_id": "YXZ-655512", 260 | }, 261 | }) 262 | }) 263 | }) 264 | 265 | device_token: str = "..." 266 | 267 | client = AsyncFirebaseClient() 268 | client.creds_from_service_account_info({...}) 269 | message = Message(apns=apns_config, token=device_token) 270 | response = await client.send(message) 271 | print(response.success) 272 | 273 | 274 | if __name__ == "__main__": 275 | asyncio.run(main()) 276 | ``` 277 | 278 | ### Topic Management 279 | You can subscribe and unsubscribe client app instances in bulk approach by passing a list of registration tokens to the subscription method to subscribe the corresponding devices to a topic: 280 | ```python3 281 | import asyncio 282 | 283 | from async_firebase import AsyncFirebaseClient 284 | 285 | 286 | async def main(): 287 | device_tokens: list[str] = ["...", "..."] 288 | 289 | client = AsyncFirebaseClient() 290 | client.creds_from_service_account_info({...}) 291 | response = await client.subscribe_devices_to_topic( 292 | device_tokens=device_tokens, topic_name="some-topic" 293 | ) 294 | print(response) 295 | 296 | 297 | if __name__ == "__main__": 298 | asyncio.run(main()) 299 | ``` 300 | 301 | To unsubscribe devices from a topic by passing registration tokens to the appropriate method: 302 | ```python3 303 | import asyncio 304 | 305 | from async_firebase import AsyncFirebaseClient 306 | 307 | 308 | async def main(): 309 | device_tokens: list[str] = ["...", "..."] 310 | 311 | client = AsyncFirebaseClient() 312 | client.creds_from_service_account_info({...}) 313 | response = await client.unsubscribe_devices_from_topic( 314 | device_tokens=device_tokens, topic_name="some-topic" 315 | ) 316 | print(response) 317 | 318 | 319 | if __name__ == "__main__": 320 | asyncio.run(main()) 321 | ``` 322 | 323 | ## License 324 | 325 | ``async-firebase`` is offered under the MIT license. 326 | 327 | ## Source code 328 | 329 | The latest developer version is available in a GitHub repository: 330 | [https://github.com/healthjoy/async-firebase](https://github.com/healthjoy/async-firebase) 331 | -------------------------------------------------------------------------------- /async_firebase/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | The module houses client to communicate with FCM - Firebase Cloud Messaging (Android, iOS and Web). 3 | 4 | Documentation for google-auth package https://google-auth.readthedocs.io/en/latest/user-guide.html that is used 5 | to authorize request which is being made to Firebase. 6 | """ 7 | 8 | import logging 9 | import typing as t 10 | import uuid 11 | from datetime import datetime, timedelta, timezone 12 | from email.mime.nonmultipart import MIMENonMultipart 13 | from importlib.metadata import version 14 | from pathlib import PurePath 15 | from urllib.parse import urlencode 16 | 17 | import httpx 18 | from google.oauth2 import service_account # type: ignore 19 | 20 | from async_firebase._config import DEFAULT_REQUEST_LIMITS, DEFAULT_REQUEST_TIMEOUT, RequestLimits, RequestTimeout 21 | from async_firebase.messages import FCMBatchResponse, FCMResponse, TopicManagementResponse 22 | from async_firebase.utils import ( 23 | FCMBatchResponseHandler, 24 | FCMResponseHandler, 25 | TopicManagementResponseHandler, 26 | join_url, 27 | serialize_mime_message, 28 | ) 29 | 30 | 31 | class AsyncClientBase: 32 | """Base asynchronous client""" 33 | 34 | BASE_URL: str = "https://fcm.googleapis.com" 35 | TOKEN_URL: str = "https://oauth2.googleapis.com/token" 36 | FCM_ENDPOINT: str = "/v1/projects/{project_id}/messages:send" 37 | FCM_BATCH_ENDPOINT: str = "/batch" 38 | # A list of accessible OAuth 2.0 scopes can be found https://developers.google.com/identity/protocols/oauth2/scopes. 39 | SCOPES: t.List[str] = [ 40 | "https://www.googleapis.com/auth/cloud-platform", 41 | ] 42 | IID_URL = "https://iid.googleapis.com" 43 | IID_HEADERS = {"access_token_auth": "true"} 44 | TOPIC_ADD_ACTION = "iid/v1:batchAdd" 45 | TOPIC_REMOVE_ACTION = "iid/v1:batchRemove" 46 | 47 | def __init__( 48 | self, 49 | credentials: t.Optional[service_account.Credentials] = None, 50 | scopes: t.Optional[t.List[str]] = None, 51 | *, 52 | request_timeout: RequestTimeout = DEFAULT_REQUEST_TIMEOUT, 53 | request_limits: RequestLimits = DEFAULT_REQUEST_LIMITS, 54 | use_http2: bool = False, 55 | ) -> None: 56 | """ 57 | :param credentials: instance of ``google.oauth2.service_account.Credentials``. 58 | Usually, you'll create these credentials with one of the helper constructors. To create credentials using a 59 | Google service account private key JSON file:: 60 | 61 | self.creds_from_service_account_file('service-account.json') 62 | 63 | Or if you already have the service account file loaded:: 64 | 65 | service_account_info = json.load(open('service_account.json')) 66 | self.creds_from_service_account_info(service_account_info) 67 | 68 | :param scopes: user-defined scopes to request during the authorization grant. 69 | :param request_timeout: advanced feature that allows to change request timeout. 70 | :param request_limits: advanced feature that allows to control the connection pool size. 71 | :param use_http2: advanced feature that allows to control usage of http protocol. 72 | """ 73 | self._credentials: service_account.Credentials = credentials 74 | self.scopes: t.List[str] = scopes or self.SCOPES 75 | 76 | self._request_timeout = request_timeout 77 | self._request_limits = request_limits 78 | self._use_http2 = use_http2 79 | self._http_client: t.Optional[httpx.AsyncClient] = None 80 | 81 | @property 82 | def _client(self) -> httpx.AsyncClient: 83 | if self._http_client is None or self._http_client.is_closed: 84 | self._http_client = httpx.AsyncClient( 85 | timeout=httpx.Timeout(**self._request_timeout.__dict__), 86 | limits=httpx.Limits(**self._request_limits.__dict__), 87 | http2=self._use_http2, 88 | ) 89 | return self._http_client 90 | 91 | def creds_from_service_account_info(self, service_account_info: t.Dict[str, str]) -> None: 92 | """ 93 | Creates a Credentials instance from parsed service account info. 94 | 95 | :param service_account_info: the service account info in Google format. 96 | """ 97 | self._credentials = service_account.Credentials.from_service_account_info( 98 | info=service_account_info, scopes=self.scopes 99 | ) 100 | 101 | def creds_from_service_account_file(self, service_account_filename: t.Union[str, PurePath]) -> None: 102 | """ 103 | Creates a Credentials instance from a service account json file. 104 | 105 | :param service_account_filename: the path to the service account json file. 106 | """ 107 | if isinstance(service_account_filename, PurePath): 108 | service_account_filename = str(service_account_filename) 109 | 110 | logging.debug("Creating credentials from file: %s", service_account_filename) 111 | self._credentials = service_account.Credentials.from_service_account_file( 112 | filename=service_account_filename, scopes=self.scopes 113 | ) 114 | 115 | async def _get_access_token(self) -> str: 116 | """Get OAuth 2 access token.""" 117 | if self._credentials.valid: 118 | return self._credentials.token 119 | 120 | headers = {"Content-Type": "application/x-www-form-urlencoded"} 121 | data = urlencode( 122 | { 123 | "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", 124 | "assertion": self._credentials._make_authorization_grant_assertion(), 125 | } 126 | ).encode("utf-8") 127 | 128 | response: httpx.Response = await self._client.post(self.TOKEN_URL, content=data, headers=headers) 129 | response_data = response.json() 130 | 131 | self._credentials.expiry = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta( 132 | seconds=response_data["expires_in"] 133 | ) 134 | self._credentials.token = response_data["access_token"] 135 | return self._credentials.token 136 | 137 | @staticmethod 138 | def get_request_id(): 139 | """Generate unique request ID.""" 140 | return str(uuid.uuid4()) 141 | 142 | @staticmethod 143 | def serialize_batch_request(request: httpx.Request) -> str: 144 | """ 145 | Convert an HttpRequest object into a string. 146 | 147 | :param request: `httpx.Request`, the request to serialize. 148 | :return: a string in application/http format. 149 | """ 150 | status_line = f"{request.method} {request.url.path} HTTP/1.1\n" 151 | major, minor = request.headers.get("content-type", "application/json").split("/") 152 | msg = MIMENonMultipart(major, minor) 153 | headers = request.headers.copy() 154 | 155 | # MIMENonMultipart adds its own Content-Type header. 156 | if "content-type" in headers: 157 | del headers["content-type"] 158 | 159 | for key, value in headers.items(): 160 | msg[key] = value 161 | msg.set_unixfrom(None) # type: ignore 162 | 163 | if request.content is not None: 164 | msg.set_payload(request.content) 165 | msg["content-length"] = str(len(request.content)) 166 | 167 | body = serialize_mime_message(msg, max_header_len=0) 168 | return f"{status_line}{body}" 169 | 170 | async def prepare_headers(self) -> t.Dict[str, str]: 171 | """Prepare HTTP headers that will be used to request Firebase Cloud Messaging.""" 172 | logging.debug("Preparing HTTP headers for all the subsequent requests") 173 | access_token: str = await self._get_access_token() 174 | return { 175 | "Authorization": f"Bearer {access_token}", 176 | "Content-Type": "application/json; UTF-8", 177 | "X-Request-Id": self.get_request_id(), 178 | "X-GOOG-API-FORMAT-VERSION": "2", 179 | "X-FIREBASE-CLIENT": "async-firebase/{0}".format(version("async-firebase")), 180 | } 181 | 182 | async def _send_request( 183 | self, 184 | url: str, 185 | response_handler: t.Union[FCMResponseHandler, FCMBatchResponseHandler, TopicManagementResponseHandler], 186 | json_payload: t.Optional[t.Dict[str, t.Any]] = None, 187 | headers: t.Optional[t.Dict[str, str]] = None, 188 | content: t.Union[str, bytes, t.Iterable[bytes], t.AsyncIterable[bytes], None] = None, 189 | ) -> t.Union[FCMResponse, FCMBatchResponse, TopicManagementResponse]: 190 | logging.debug( 191 | "Requesting POST %s, payload: %s, content: %s, headers: %s", 192 | url, 193 | json_payload, 194 | content, 195 | headers, 196 | ) 197 | try: 198 | raw_fcm_response: httpx.Response = await self._client.post( 199 | url, 200 | json=json_payload, 201 | headers=headers or await self.prepare_headers(), 202 | content=content, 203 | ) 204 | raw_fcm_response.raise_for_status() 205 | except httpx.HTTPError as exc: 206 | response = response_handler.handle_error(exc) 207 | else: 208 | logging.debug( 209 | "Response Code: %s, Time spent to make a request: %s", 210 | raw_fcm_response.status_code, 211 | raw_fcm_response.elapsed, 212 | ) 213 | response = response_handler.handle_response(raw_fcm_response) 214 | 215 | return response 216 | 217 | async def send_request( 218 | self, 219 | uri: str, 220 | response_handler: t.Union[FCMResponseHandler, FCMBatchResponseHandler], 221 | json_payload: t.Optional[t.Dict[str, t.Any]] = None, 222 | headers: t.Optional[t.Dict[str, str]] = None, 223 | content: t.Union[str, bytes, t.Iterable[bytes], t.AsyncIterable[bytes], None] = None, 224 | ) -> t.Union[FCMResponse, FCMBatchResponse]: 225 | """ 226 | Sends an HTTP call using the ``httpx`` library to FCM. 227 | 228 | :param uri: URI to be requested. 229 | :param response_handler: the model to handle response. 230 | :param json_payload: request JSON payload 231 | :param headers: request headers. 232 | :param content: request content 233 | :return: HTTP response 234 | """ 235 | url = join_url(self.BASE_URL, uri) 236 | return await self._send_request( # type: ignore 237 | url=url, response_handler=response_handler, json_payload=json_payload, headers=headers, content=content 238 | ) 239 | 240 | async def send_iid_request( 241 | self, 242 | uri: str, 243 | response_handler: TopicManagementResponseHandler, 244 | json_payload: t.Optional[t.Dict[str, t.Any]] = None, 245 | headers: t.Optional[t.Dict[str, str]] = None, 246 | content: t.Union[str, bytes, t.Iterable[bytes], t.AsyncIterable[bytes], None] = None, 247 | ) -> TopicManagementResponse: 248 | """ 249 | Sends an HTTP call using the ``httpx`` library to the IID service for topic management functionality. 250 | 251 | :param uri: URI to be requested. 252 | :param response_handler: the model to handle response. 253 | :param json_payload: request JSON payload 254 | :param headers: request headers. 255 | :param content: request content 256 | :return: HTTP response 257 | """ 258 | url = join_url(self.IID_URL, uri) 259 | headers = headers or await self.prepare_headers() 260 | headers.update(self.IID_HEADERS) 261 | return await self._send_request( # type: ignore 262 | url=url, response_handler=response_handler, json_payload=json_payload, headers=headers, content=content 263 | ) 264 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 4.1.0 4 | * Extend ``async_firebase.messages.AndroidNotification`` object with new attribute ``proxy``. The attribute sets whether the notification can be proxied. Must be one of ``allow``, ``deny``, or ``if_priority_lowered``. 5 | 6 | ## 4.0.0 7 | * [BREAKING] Drop support of **Python 3.8** and update dependencies. 8 | 9 | ## 3.12.1 10 | * [FIX] `google-auth` deals with offset-maive datetime objct when validating token. Method `async_firebase.base.AsyncClientBase._get_access_token` is adjusted to replace tzinfo with `None`. 11 | 12 | ## 3.12.0 13 | * Extend ``async_firebase.messages.AndroidNotification`` object with new attribute ``Visibility``. The attribute sets different visibility levels of a notification. 14 | 15 | ## 3.11.0 16 | * [MINOR] Usage of `datetime.utcnow()` is deprecated in Python 3.12, so it was replaced with a recommended way, which is `datetime.now(timezone.utc)` 17 | 18 | ## 3.10.0 19 | * `async_firebase` now works with python 3.13 20 | 21 | ## 3.9.0 22 | * Add ability to say that HTTP/2 protocol should be used when making request. Please find an example below: 23 | ```python 24 | 25 | client = AsyncFirebaseClient(use_http2=True) 26 | ``` 27 | 28 | ## 3.8.0 29 | * Endow ``async_firebase.messages.MulticastMessage`` with the ability to set FCM options. 30 | 31 | ## 3.7.0 32 | * `async-firebase` has been allowed to perform basic topic management tasks from the server side. Given their registration token(s), you can now subscribe and unsubscribe client app instances in bulk using server logic. You can subscribe client app instances to any existing topic, or you can create a new topic. 33 | 34 | ## 3.6.3 35 | * [FIX] The ``join_url`` util has been tuned to encode the URL properly when the path is present. That led to the invalid URL being built. 36 | 37 | ## 3.6.2 38 | * Resolve a couple of security concerns by updating `cryptography` package to `42.0.4`. 39 | * [High] cryptography NULL pointer dereference with pkcs12.serialize_key_and_certificates when called with a non-matching certificate and private key and an hmac_hash override 40 | * [High] Python Cryptography package vulnerable to Bleichenbacher timing oracle attack 41 | * [Moderate] Null pointer dereference in PKCS12 parsing 42 | * [Moderate] cryptography vulnerable to NULL-dereference when loading PKCS7 certificates 43 | 44 | ## 3.6.1 45 | * Remove unintended quoting of the column char in the API URLs 46 | 47 | ## 3.6.0 48 | * Introduce send_each and send_each_for_multicast methods 49 | * Add deprecation warnings to send_all and send_multicast methods, because they use the API that 50 | Google may deprecate soon. The newly introduced methods should be safe to use. 51 | 52 | ## 3.5.0 53 | * [BREAKING] Drop support of **Python 3.7** 54 | 55 | ## 3.4.1 56 | * [FIX] The batch URL is composed incorrectly, which causes an HTTP 404 response to be received. 57 | 58 | ## 3.4.0 59 | * Refactored ``async_firebase.base.AsyncClientBase`` to take advantage of connection pool. So the HTTP client will be created once during class ``async_firebase.client.AsyncFirebaseClient`` instantiation. 60 | 61 | ## 3.3.0 62 | * `async_firebase` now works with python 3.12 63 | 64 | ## 3.2.0 65 | * ``AsyncFirebaseClient`` empower with advanced features to configure request behaviour such as timeout, or connection pooling. 66 | Example: 67 | ```python 68 | 69 | from async_firebase.client import AsyncFirebaseClient, RequestTimeout 70 | 71 | # This will disable timeout 72 | client = AsyncFirebaseClient(..., request_timeout=RequestTimeout(None)) 73 | client.send(...) 74 | ``` 75 | 76 | ## 3.1.1 77 | * [FIX] The push notification could not be sent to topic because ``messages.Message.token`` is declared as required attribute though it should be optional. 78 | ``messages.Message.token`` turned into Optional attribute. 79 | 80 | ## 3.1.0 81 | * The limit on the number of messages (>= 500) that can be sent using the ``send_all`` method has been restored. 82 | 83 | ## 3.0.0 84 | Remastering client interface 85 | * [BREAKING] The methods ``push`` and ``push_multicast`` renamed to ``send`` and ``send_multicast`` accordingly. 86 | * [BREAKING] The signatures of the methods ``send`` and ``send_multicast`` have been changed. 87 | * Method ``send`` accepts instance of ``messages.Message`` and returns ``messages.FCMBatchResponse`` 88 | * Method ``send_multicast`` accepts instance of ``messages.MulticastMessage`` and returns ``messages.FCMBatchResponse`` 89 | * New method ``send_all`` to send messages in a single batch has been added. It takes a list of ``messages.Message`` instances and returns ``messages.FCMBatchResponse``. 90 | * ``README.md`` has been updated to highlight different in interfaces for versions prior **3.x** and after 91 | * Improved naming: 92 | * ``messages.FcmPushMulticastResponse`` to ``messages.FCMBatchResponse`` 93 | * ``messages.FcmPushResponse`` to ``messages.FCMResponse`` 94 | * ``utils.FcmReponseType`` to ``utils.FCMResponseType`` 95 | * ``utils.FcmResponseHandler`` to ``utils.FCMResponseHandlerBase`` 96 | * ``utils.FcmPushResponseHandler`` to ``utils.FCMResponseHandler`` 97 | * ``utils.FcmPushMulticastResponseHandler`` to ``utils.FCMBatchResponseHandler`` 98 | * Type annotations and doc string were updated according to new naming. 99 | 100 | ## 2.7.0 101 | * Class ``AsyncFirebaseClient`` has been refactored. Communication related code extracted into base class. 102 | 103 | ## 2.6.1 104 | * ``async_firebase.encoders.aps_encoder`` no longer clears ``custom_data`` dictionary, as this causes subsequent notifications to not get any content in ``custom_data`` dictionary. 105 | 106 | ## 2.6.0 107 | * Add object for sending Web Push. 108 | 109 | ## 2.5.1 110 | * Fix WebPush type annotation 111 | 112 | ## 2.5.0 113 | * Adds field ``notification_count`` to ``AndroidNotification`` message. 114 | 115 | ## 2.4.0 116 | * [BREAKING] Drop support of **Python 3.6** 117 | * Update dependencies 118 | * Make implicit optional type hints PEP 484 compliant. 119 | 120 | ## 2.3.0 121 | Method ``client.build_android_config`` has been adjusted, so when ``data`` parameter is passed but the value is not set (equal to `None`), 122 | turn in into ``"null"`` 123 | 124 | ## 2.2.0 125 | async_firebase now works with **Python 3.11** 126 | * Removes ``asynctest`` as it is no longer maintained [ref](https://github.com/Martiusweb/asynctest/issues/158#issuecomment-785872568) 127 | 128 | ## 2.1.0 129 | * ``messages.Notification`` object now supports attribute ``image`` that allows to set image url of the notification 130 | 131 | ## 2.0.3 132 | A few new error types have been added to support the errors that FCM API may return: 133 | - InvalidArgumentError 134 | - FailedPreconditionError 135 | - OutOfRangeError 136 | - UnauthenticatedError 137 | - PermissionDeniedError 138 | - NotFoundError 139 | - AbortedError 140 | - AlreadyExistsError 141 | - ConflictError 142 | - ResourceExhaustedError 143 | - CancelledError 144 | - DataLossError 145 | - UnknownError 146 | - InternalError 147 | - UnavailableError 148 | - DeadlineExceededError 149 | 150 | ## 2.0.2 151 | * Adjust type annotations for some errors: 152 | 153 | **async_firebase/errors.py** 154 | ```python 155 | 156 | class AsyncFirebaseError(BaseAsyncFirebaseError): 157 | """A prototype for all AF Errors. 158 | 159 | This error and its subtypes and the reason to rise them are consistent with Google's errors, 160 | that may be found in `firebase-admin-python` in `firebase_admin.exceptions module`. 161 | """ 162 | 163 | def __init__( 164 | self, 165 | code: str, 166 | message: str, 167 | <<< cause: t.Optional[Exception] = None, 168 | >>> cause: t.Union[httpx.HTTPStatusError, httpx.RequestError, None] = None, 169 | http_response: t.Optional[httpx.Response] = None, 170 | ): 171 | ``` 172 | 173 | **async_firebase/messages.py** 174 | ```python 175 | class FcmPushResponse: 176 | """The response received from an individual batched request to the FCM API. 177 | 178 | The interface of this object is compatible with SendResponse object of 179 | the Google's firebase-admin-python package. 180 | """ 181 | 182 | def __init__( 183 | self, 184 | fcm_response: t.Optional[t.Dict[str, str]] = None, 185 | <<< exception: t.Optional[Exception] = None 186 | >>> exception: t.Optional[AsyncFirebaseError] = None 187 | ): 188 | ``` 189 | 190 | ## 2.0.1 191 | * Fix ``TypeError`` on create ``AsyncFirebaseError`` subclass instance 192 | 193 | ## 2.0.0 194 | * `push` method now returns a `FcmPushResponse` object, that has a fully compatible interface with SendResponse object of the Google's firebase-admin-python package. 195 | * `push_multicast` method now returns a `FcmPushMulticastResponse` object, that has a fully compatible interface with BatchResponse object of the Google's firebase-admin-python package. 196 | * The aforementioned methods may still rise exceptions when assembling the message for the request. 197 | * A bunch of exceptions has been added. 198 | 199 | ## 1.9.1 200 | 201 | * [FIX] Invalid batch requests fixed in``push_multicast`` method. 202 | * [FIX] Data mutation issues that lead to unexpected behaviour fixed in ``assemble_push_notification`` method. 203 | * Message ``messages.MulticastMessage`` was deprecated as it's no longer used. 204 | 205 | ## 1.9.0 206 | 207 | * Add support of [MulticastMessage](https://firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/messaging/MulticastMessage) 208 | 209 | ## 1.8.0 210 | 211 | * Clean up. Remove dependencies and code that is no longer used. 212 | * Update dependencies: 213 | * Removed backports.entry-points-selectable (1.1.1) 214 | * Removed chardet (4.0.0) 215 | * Removed requests (2.25.1) 216 | * Removed urllib3 (1.26.7) 217 | * Updated idna (2.10 -> 3.3) 218 | * Updated pyparsing (3.0.6 -> 3.0.7) 219 | * Updated attrs (21.2.0 -> 21.4.0) 220 | * Updated charset-normalizer (2.0.9 -> 2.0.12) 221 | * Updated filelock (3.4.0 -> 3.4.1) 222 | * Updated click (8.0.3 -> 8.0.4) 223 | * Updated freezegun (1.1.0 -> 1.2.0) 224 | * Updated identify (2.4.0 -> 2.4.4) 225 | * Updated regex (2021.11.10 -> 2022.3.2) 226 | * Installed tomli (1.2.3) 227 | * Updated typed-ast (1.4.3 -> 1.5.2) 228 | * Updated typing-extensions (4.0.1 -> 4.1.1) 229 | * Updated virtualenv (20.10.0 -> 20.13.3) 230 | * Updated google-auth (2.1.0 -> 2.6.0) 231 | * Updated mypy (0.910 -> 0.931) 232 | 233 | ## 1.7.0 234 | 235 | * Make _get_access_token async 236 | 237 | ## 1.6.0 238 | 239 | * Add support of Python 3.10 240 | * Fix typos in README.md 241 | 242 | ## 1.5.0 243 | 244 | * Update dependencies 245 | 246 | ## 1.4.0 247 | 248 | * Added channel_id option to Android config ([spec](https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#AndroidNotification)) 249 | 250 | ## 1.3.5 251 | 252 | * [FIX] Adjust APS encoder in order to properly construct background push notification. 253 | 254 | ## 1.3.4 255 | 256 | * [FIX] Set properly attribute ``content-available`` for APNS payload. The attribute indicates that push 257 | notification should be considered [background](https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/pushing_background_updates_to_your_app). 258 | 259 | ## 1.3.3 260 | 261 | * [FIX] Use numeric representation for boolean attributes ``mutable-content`` and ``content-available``. 262 | 263 | ## 1.3.2 264 | 265 | * [FIX] Encode ``Aps`` message according to APNS [specification](https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/generating_a_remote_notification) 266 | * [FIX] Util ``cleanup_firebase_message`` to remove null values for dict object. 267 | * [CHORE] Update dependencies 268 | 269 | ## 1.3.1 270 | 271 | * [FIX] ``APNSPayload`` message no longer support attribute ``custom_data`` 272 | 273 | ## 1.3.0 274 | 275 | * [FIX] APNS custom data properly incorporate into the push notification payload. So instead of passing ``custom_data`` 276 | as-is into payload and by that introducing one more nested level, extract all the key from ``custom_data`` and 277 | put them on the same level as ``aps`` attribute. 278 | 279 | ## 1.2.1 280 | 281 | * Added verbosity when making request to Firebase 282 | 283 | ## 1.2.0 284 | 285 | * Added instrumentation to cleanup Firebase message before it can gets send. This is requirements that Firebase put on 286 | us, otherwise it fails with 400 error code, which basically says that payload is not well formed. 287 | 288 | ## 1.1.0 289 | 290 | * Update 3rd-party dependencies: 291 | - `google-auth = "~1.27.1"` 292 | - `httpx = "<1.0.0"` 293 | 294 | ## 1.0.0 295 | 296 | * Remastering main client. 297 | * method ``build_common_message`` completely dropped in favor of concept of message structures (module ``messages``). 298 | 299 | * the signature of method ``push`` has changed. From now on, it expects to receive instances of ``messages.*`` for 300 | Android config and APNS config rather than raw data, which then has to be dynamically analyzed and mapped to the 301 | most appropriate message type. 302 | 303 | * static methods ``build_android_message`` and ``build_apns_message`` renamed to ``build_android_config`` and 304 | ``build_apns_config``. 305 | 306 | * static methods ``build_android_config`` and ``build_apns_config`` return ``messages.AndroidConfig`` and 307 | ``messages.APNSConfig`` respectively and aimed to simplify the process of creating configurations. 308 | 309 | * Introduce module ``messages`` the main aim is to simplify the process of constructing Push notification messages. 310 | The module houses the message structures that can be used to construct a push notification payload. 311 | 312 | * Fix CD workflow. Make step ``release`` dependent on ``create-virtualenv``. 313 | 314 | * Update ``README.md`` with more examples. 315 | 316 | * Every request to Firebase tagged with ``X-Request-Id`` 317 | 318 | ## 0.4.0 319 | 320 | * Method ``push()`` no longer has parameter ``alert_text``. The parameter ``notification_body`` should be used 321 | instead. 322 | 323 | ## 0.3.0 324 | 325 | * Fix version 326 | 327 | ## 0.2.0 328 | 329 | * Update README.md 330 | 331 | ## 0.1.0 332 | 333 | * First release on PyPI. 334 | -------------------------------------------------------------------------------- /async_firebase/utils.py: -------------------------------------------------------------------------------- 1 | import io 2 | import json 3 | import typing as t 4 | from abc import ABC, abstractmethod 5 | from copy import deepcopy 6 | from dataclasses import fields, is_dataclass 7 | from email.generator import Generator 8 | from email.mime.multipart import MIMEMultipart 9 | from email.mime.nonmultipart import MIMENonMultipart 10 | from email.parser import FeedParser 11 | from enum import Enum 12 | from urllib.parse import quote, urlencode, urljoin 13 | 14 | import httpx 15 | 16 | from async_firebase import errors 17 | from async_firebase.errors import ( 18 | AsyncFirebaseError, 19 | DeadlineExceededError, 20 | FcmErrorCode, 21 | QuotaExceededError, 22 | SenderIdMismatchError, 23 | ThirdPartyAuthError, 24 | UnavailableError, 25 | UnknownError, 26 | UnregisteredError, 27 | ) 28 | from async_firebase.messages import FCMBatchResponse, FCMResponse, TopicManagementResponse 29 | 30 | 31 | def join_url( 32 | base: str, 33 | *parts: t.Union[str, int], 34 | params: t.Optional[dict] = None, 35 | leading_slash: bool = False, 36 | trailing_slash: bool = False, 37 | ) -> str: 38 | """Construct a full ("absolute") URL by combining a "base URL" (base) with another URL (url) parts. 39 | 40 | :param base: base URL part 41 | :param parts: another url parts that should be joined 42 | :param params: dict with query params 43 | :param leading_slash: flag to force leading slash 44 | :param trailing_slash: flag to force trailing slash 45 | 46 | :return: full URL 47 | """ 48 | url = base 49 | if parts: 50 | quoted_and_stripped_parts = [quote(str(part).strip("/"), safe=": /") for part in parts] 51 | url = "/".join([base.strip("/"), *quoted_and_stripped_parts]) 52 | 53 | # trailing slash can be important 54 | if trailing_slash: 55 | url = f"{url}/" 56 | # as well as a leading slash 57 | if leading_slash: 58 | url = f"/{url}" 59 | 60 | if params: 61 | url = urljoin(url, "?{}".format(urlencode(params))) 62 | 63 | return url 64 | 65 | 66 | def remove_null_values(dict_value: t.Dict[str, t.Any]) -> t.Dict[str, t.Any]: 67 | """Remove Falsy values from the dictionary.""" 68 | return {k: v for k, v in dict_value.items() if v not in [None, [], {}]} 69 | 70 | 71 | def cleanup_firebase_message(dataclass_obj, dict_factory: t.Callable = dict) -> dict: 72 | """ 73 | The instrumentation to cleanup firebase message from null values. 74 | 75 | Example:: 76 | 77 | considering following dataclass 78 | 79 | msg = Message( 80 | token='qwe', 81 | data={}, 82 | notification=Notification(title='push-title', body='push-body'), 83 | android=None, 84 | webpush={}, 85 | apns=APNSConfig( 86 | headers={'hdr': 'qwe'}, 87 | payload=APNSPayload( 88 | aps=Aps( 89 | alert=None, 90 | badge=None, 91 | sound='generic', 92 | content_available=None, 93 | category=None, 94 | thread_id=None, 95 | mutable_content=None, 96 | custom_data={} 97 | ), 98 | custom_data={} 99 | ) 100 | ), 101 | topic=None, 102 | condition=None 103 | ) 104 | 105 | >>> dataclass_to_dict_remove_null_values(msg) 106 | { 107 | 'token': 'qwe', 108 | 'notification': {'title': 'push-title', 'body': 'push-body'}, 109 | 'apns': { 110 | 'headers': {'hdr': 'qwe'}, 111 | 'payload': { 112 | 'aps': {'sound': 'generic'} 113 | } 114 | } 115 | } 116 | 117 | :param dataclass_obj: instance of dataclass. This suppose to be instance of ``messages.PushNotification`` or 118 | ``messages.Message``. 119 | :param dict_factory: if given, ``dict_factory`` will be used instead of built-in dict. 120 | The function applies recursively to field values that are dataclass instances. 121 | :return: the fields of a dataclass instance as a new dictionary mapping field names to field values. 122 | """ 123 | if is_dataclass(dataclass_obj): 124 | result = [] 125 | for f in fields(dataclass_obj): 126 | value = cleanup_firebase_message(getattr(dataclass_obj, f.name), dict_factory) 127 | if isinstance(value, Enum): 128 | value = value.value 129 | result.append((f.name, value)) 130 | return remove_null_values(dict_factory(result)) 131 | elif isinstance(dataclass_obj, (list, tuple)): 132 | return type(dataclass_obj)(cleanup_firebase_message(v, dict_factory) for v in dataclass_obj) # type: ignore 133 | elif isinstance(dataclass_obj, dict): 134 | return remove_null_values({k: cleanup_firebase_message(v, dict_factory) for k, v in dataclass_obj.items()}) 135 | return deepcopy(dataclass_obj) 136 | 137 | 138 | def serialize_mime_message( 139 | message: t.Union[MIMEMultipart, MIMENonMultipart], 140 | mangle_from: t.Optional[bool] = None, 141 | max_header_len: t.Optional[int] = None, 142 | ) -> str: 143 | """ 144 | Serialize the MIME type message. 145 | 146 | :param message: MIME type message 147 | :param mangle_from: is a flag that, when True (the default if policy 148 | is not set), escapes From_ lines in the body of the message by putting 149 | a `>' in front of them. 150 | :param max_header_len: specifies the longest length for a non-continued 151 | header. When a header line is longer (in characters, with tabs 152 | expanded to 8 spaces) than max_header_len, the header will split as 153 | defined in the Header class. Set max_header_len to zero to disable 154 | header wrapping. The default is 78, as recommended (but not required) 155 | by RFC 2822. 156 | :return: the entire contents of the object. 157 | """ 158 | fp = io.StringIO() 159 | gen = Generator(fp, mangle_from_=mangle_from, maxheaderlen=max_header_len) 160 | gen.flatten(message, unixfrom=False) 161 | return fp.getvalue() 162 | 163 | 164 | FCMResponseType = t.TypeVar("FCMResponseType", FCMResponse, FCMBatchResponse, TopicManagementResponse) 165 | 166 | 167 | class FCMResponseHandlerBase(ABC, t.Generic[FCMResponseType]): 168 | ERROR_CODE_TO_EXCEPTION_TYPE: t.Dict[str, t.Type[AsyncFirebaseError]] = { 169 | FcmErrorCode.INVALID_ARGUMENT.value: errors.InvalidArgumentError, 170 | FcmErrorCode.FAILED_PRECONDITION.value: errors.FailedPreconditionError, 171 | FcmErrorCode.OUT_OF_RANGE.value: errors.OutOfRangeError, 172 | FcmErrorCode.UNAUTHENTICATED.value: errors.UnauthenticatedError, 173 | FcmErrorCode.PERMISSION_DENIED.value: errors.PermissionDeniedError, 174 | FcmErrorCode.NOT_FOUND.value: errors.NotFoundError, 175 | FcmErrorCode.ABORTED.value: errors.AbortedError, 176 | FcmErrorCode.ALREADY_EXISTS.value: errors.AlreadyExistsError, 177 | FcmErrorCode.CONFLICT.value: errors.ConflictError, 178 | FcmErrorCode.RESOURCE_EXHAUSTED.value: errors.ResourceExhaustedError, 179 | FcmErrorCode.CANCELLED.value: errors.CancelledError, 180 | FcmErrorCode.DATA_LOSS.value: errors.DataLossError, 181 | FcmErrorCode.UNKNOWN.value: errors.UnknownError, 182 | FcmErrorCode.INTERNAL.value: errors.InternalError, 183 | FcmErrorCode.UNAVAILABLE.value: errors.UnavailableError, 184 | FcmErrorCode.DEADLINE_EXCEEDED.value: errors.DeadlineExceededError, 185 | } 186 | 187 | HTTP_STATUS_TO_ERROR_CODE = { 188 | 400: FcmErrorCode.INVALID_ARGUMENT.value, 189 | 401: FcmErrorCode.UNAUTHENTICATED.value, 190 | 403: FcmErrorCode.PERMISSION_DENIED.value, 191 | 404: FcmErrorCode.NOT_FOUND.value, 192 | 409: FcmErrorCode.CONFLICT.value, 193 | 412: FcmErrorCode.FAILED_PRECONDITION.value, 194 | 429: FcmErrorCode.RESOURCE_EXHAUSTED.value, 195 | 500: FcmErrorCode.INTERNAL.value, 196 | 503: FcmErrorCode.UNAVAILABLE.value, 197 | } 198 | 199 | FCM_ERROR_TYPES = { 200 | "APNS_AUTH_ERROR": ThirdPartyAuthError, 201 | "QUOTA_EXCEEDED": QuotaExceededError, 202 | "SENDER_ID_MISMATCH": SenderIdMismatchError, 203 | "THIRD_PARTY_AUTH_ERROR": ThirdPartyAuthError, 204 | "UNREGISTERED": UnregisteredError, 205 | } 206 | 207 | @abstractmethod 208 | def handle_response(self, response: httpx.Response) -> FCMResponseType: 209 | pass 210 | 211 | @abstractmethod 212 | def handle_error(self, error: httpx.HTTPError) -> FCMResponseType: 213 | pass 214 | 215 | @staticmethod 216 | def _handle_response(response: httpx.Response) -> FCMResponse: 217 | return FCMResponse(fcm_response=response.json()) 218 | 219 | def _handle_error(self, error: httpx.HTTPError) -> FCMResponse: 220 | exc = ( 221 | (isinstance(error, httpx.HTTPStatusError) and self._handle_fcm_error(error)) 222 | or (isinstance(error, httpx.HTTPError) and self._handle_request_error(error)) 223 | or AsyncFirebaseError( 224 | code=FcmErrorCode.UNKNOWN.value, 225 | message="Unexpected error has happened when hitting the FCM API", 226 | cause=error, 227 | ) 228 | ) 229 | return FCMResponse(exception=exc) 230 | 231 | def _handle_request_error(self, error: httpx.HTTPError): 232 | if isinstance(error, httpx.TimeoutException): 233 | return DeadlineExceededError(message=f"Timed out while making an API call: {error}", cause=error) 234 | elif isinstance(error, httpx.ConnectError): 235 | return UnavailableError(message=f"Failed to establish a connection: {error}", cause=error) 236 | elif not hasattr(error, "response"): 237 | return UnknownError(message="Unknown error while making a remote service call: {error}", cause=error) 238 | 239 | return self._get_error_by_status_code(t.cast(httpx.HTTPStatusError, error)) 240 | 241 | def _get_error_by_status_code(self, error: httpx.HTTPStatusError): 242 | error_data = self._parse_platform_error(error.response) 243 | code = error_data.get("status", self._http_status_to_error_code(error.response.status_code)) 244 | err_type = self._error_code_to_exception_type(code) 245 | return err_type(message=error_data["message"], cause=error, http_response=error.response) # type: ignore 246 | 247 | def _handle_fcm_error(self, error: httpx.HTTPStatusError): 248 | error_data = self._parse_platform_error(error.response) 249 | err_type = self._get_fcm_error_type(error_data) 250 | return err_type(error_data["message"], cause=error, http_response=error.response) if err_type else None 251 | 252 | @classmethod 253 | def _http_status_to_error_code(cls, http_status_code: int) -> str: 254 | return cls.HTTP_STATUS_TO_ERROR_CODE.get(http_status_code, FcmErrorCode.UNKNOWN.value) 255 | 256 | @classmethod 257 | def _error_code_to_exception_type(cls, error_code: str) -> t.Type[AsyncFirebaseError]: 258 | return cls.ERROR_CODE_TO_EXCEPTION_TYPE.get(error_code, errors.UnknownError) 259 | 260 | @classmethod 261 | def _get_fcm_error_type(cls, error_data: dict): 262 | if not error_data: 263 | return None 264 | 265 | fcm_code = None 266 | for detail in error_data.get("details", []): 267 | if detail.get("@type") == "type.googleapis.com/google.firebase.fcm.v1.FcmError": 268 | fcm_code = detail.get("errorCode") 269 | break 270 | 271 | if not fcm_code: 272 | return None 273 | 274 | return cls.FCM_ERROR_TYPES.get(fcm_code) 275 | 276 | @staticmethod 277 | def _parse_platform_error(response: httpx.Response): 278 | """Extract the code and mesage from GCP API Error HTTP response.""" 279 | data: dict = {} 280 | try: 281 | data = response.json() 282 | except ValueError: 283 | pass 284 | 285 | error_data = data.get("error", {}) 286 | if not error_data.get("message"): 287 | error_data["message"] = ( 288 | f"Unexpected HTTP response with status: {response.status_code}; body: {response.content!r}" 289 | ) 290 | return error_data 291 | 292 | 293 | class FCMResponseHandler(FCMResponseHandlerBase[FCMResponse]): 294 | def handle_error(self, error: httpx.HTTPError) -> FCMResponse: 295 | return self._handle_error(error) 296 | 297 | def handle_response(self, response: httpx.Response) -> FCMResponse: 298 | return self._handle_response(response) 299 | 300 | 301 | class FCMBatchResponseHandler(FCMResponseHandlerBase[FCMBatchResponse]): 302 | def handle_error(self, error: httpx.HTTPError): 303 | fcm_response = self._handle_error(error) 304 | return FCMBatchResponse(responses=[fcm_response]) 305 | 306 | def handle_response(self, response: httpx.Response): 307 | fcm_push_responses = [] 308 | responses = self._deserialize_batch_response(response) 309 | for single_resp in responses: 310 | if single_resp.status_code >= 300: 311 | exc = httpx.HTTPStatusError("FCM Error", response=single_resp, request=response.request) 312 | fcm_push_responses.append(self._handle_error(exc)) 313 | else: 314 | fcm_push_responses.append(self._handle_response(single_resp)) 315 | 316 | return FCMBatchResponse(responses=fcm_push_responses) 317 | 318 | @staticmethod 319 | def _deserialize_batch_response(response: httpx.Response) -> t.List[httpx.Response]: 320 | """Convert batch response into list of `httpx.Response` responses for each multipart. 321 | 322 | :param response: string, headers and body as a string. 323 | :return: list of `httpx.Response` responses. 324 | """ 325 | # Prepend with a content-type header so FeedParser can handle it. 326 | header = f"content-type: {response.headers['content-type']}\r\n\r\n" 327 | # PY3's FeedParser only accepts unicode. So we should decode content here, and encode each payload again. 328 | content = response.content.decode() 329 | for_parser = f"{header}{content}" 330 | 331 | parser = FeedParser() 332 | parser.feed(for_parser) 333 | mime_response = parser.close() 334 | 335 | if not mime_response.is_multipart(): 336 | raise ValueError("Response not in multipart/mixed format.") 337 | 338 | responses = [] 339 | for part in mime_response.get_payload(): 340 | request_id = part["Content-ID"].split("-", 1)[-1] # type: ignore 341 | status_line, payload = part.get_payload().split("\n", 1) # type: ignore 342 | _, status_code, _ = status_line.split(" ", 2) 343 | status_code = int(status_code) 344 | 345 | # Parse the rest of the response 346 | parser = FeedParser() 347 | parser.feed(payload) 348 | msg = parser.close() 349 | msg["status_code"] = t.cast(str, status_code) 350 | 351 | # Create httpx.Response from the parsed headers. 352 | resp = httpx.Response( 353 | status_code=status_code, 354 | headers=httpx.Headers({"Content-Type": msg.get_content_type(), "X-Request-ID": request_id}), 355 | content=msg.get_payload(), 356 | json=json.loads(msg.get_payload()), # type: ignore 357 | ) 358 | responses.append(resp) 359 | 360 | return responses 361 | 362 | 363 | class TopicManagementResponseHandler(FCMResponseHandlerBase[TopicManagementResponse]): 364 | def handle_error(self, error: httpx.HTTPError) -> TopicManagementResponse: 365 | exc = ( 366 | (isinstance(error, httpx.HTTPStatusError) and self._handle_fcm_error(error)) 367 | or (isinstance(error, httpx.HTTPError) and self._handle_request_error(error)) 368 | or AsyncFirebaseError( 369 | code=FcmErrorCode.UNKNOWN.value, 370 | message="Unexpected error has happened when hitting the FCM API", 371 | cause=error, 372 | ) 373 | ) 374 | return TopicManagementResponse(exception=exc) 375 | 376 | def handle_response(self, response: httpx.Response) -> TopicManagementResponse: 377 | return TopicManagementResponse(response) 378 | -------------------------------------------------------------------------------- /async_firebase/messages.py: -------------------------------------------------------------------------------- 1 | """The module houses the message structures that can be used to construct a push notification payload. 2 | 3 | """ 4 | 5 | import typing as t 6 | from dataclasses import dataclass, field 7 | from enum import Enum, IntEnum 8 | 9 | import httpx 10 | 11 | from async_firebase.errors import AsyncFirebaseError 12 | 13 | 14 | class Visibility(IntEnum): 15 | """Available visibility levels. 16 | 17 | To get more insights please follow the reference 18 | https://developer.android.com/reference/android/app/Notification#visibility 19 | """ 20 | 21 | PRIVATE = 0 22 | PUBLIC = 1 23 | SECRET = -1 24 | 25 | 26 | class NotificationProxy(Enum): 27 | """Available proxy behaviors. 28 | 29 | To get more insights please follow the reference 30 | https://firebase.google.com/docs/reference/admin/dotnet/namespace/firebase-admin/messaging#notificationproxy 31 | """ 32 | 33 | ALLOW = "allow" 34 | DENY = "deny" 35 | IF_PRIORITY_LOWERED = "if_priority_lowered" 36 | 37 | 38 | @dataclass 39 | class AndroidNotification: 40 | """ 41 | Android-specific notification parameters. 42 | 43 | Attributes: 44 | title: title of the notification (optional). If specified, overrides the title set via ``messages.Notification``. 45 | body: body of the notification (optional). If specified, overrides the body set via ``messages.Notification``. 46 | icon: icon of the notification (optional). 47 | color: color of the notification icon expressed in ``#rrggbb`` form (optional). 48 | sound: sound to be played when the device receives the notification (optional). This is usually the file name of 49 | the sound resource. 50 | tag: tag of the notification (optional). This is an identifier used to replace existing notifications in the 51 | notification drawer. If not specified, each request creates a new notification. 52 | click_action: the action associated with a user click on the notification (optional). If specified, an activity with 53 | a matching intent filter is launched when a user clicks on the notification. 54 | body_loc_key: key of the body string in the app's string resources to use to localize the body text (optional). 55 | body_loc_args: a list of resource keys that will be used in place of the format specifiers in ``body_loc_key`` 56 | (optional). 57 | title_loc_key: key of the title string in the app's string resources to use to localize the title text (optional). 58 | title_loc_args: a list of resource keys that will be used in place of the format specifiers in ``title_loc_key`` 59 | (optional). 60 | channel_id: The notification's channel id (new in Android O). The app must create a channel with this channel ID 61 | before any notification with this channel ID is received. If you don't send this channel ID in the request, 62 | or if the channel ID provided has not yet been created by the app, FCM uses the channel ID specified in the 63 | app manifest. 64 | notification_count: the number of items this notification represents (optional). If zero or unspecified, systems 65 | that support badging use the default, which is to increment a number displayed on the long-press menu each time 66 | a new notification arrives. 67 | visibility: sets the different visibility levels of a notification. More about Visibility levels can be found by 68 | reference https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#Visibility 69 | proxy: gets or sets the proxy behavior of this notification. Must be one of ``allow``, ``deny``, or 70 | ``if_priority_lowered``. If unspecified, it remains undefined and defers to the FCM backend's default mapping. 71 | """ 72 | 73 | title: t.Optional[str] = None 74 | body: t.Optional[str] = None 75 | icon: t.Optional[str] = None 76 | color: t.Optional[str] = None 77 | sound: t.Optional[str] = None 78 | tag: t.Optional[str] = None 79 | click_action: t.Optional[str] = None 80 | body_loc_key: t.Optional[str] = None 81 | body_loc_args: t.List[str] = field(default_factory=list) 82 | title_loc_key: t.Optional[str] = None 83 | title_loc_args: t.List[str] = field(default_factory=list) 84 | channel_id: t.Optional[str] = None 85 | notification_count: t.Optional[int] = None 86 | visibility: t.Optional[Visibility] = None 87 | proxy: t.Optional[NotificationProxy] = None 88 | 89 | 90 | @dataclass 91 | class AndroidConfig: 92 | """ 93 | Android-specific options that can be included in a message. 94 | 95 | Attributes: 96 | collapse_key: collapse key string for the message (optional). This is an identifier for a group of messages that 97 | can be collapsed, so that only the last message is sent when delivery can be resumed. A maximum of 4 different 98 | collapse keys may be active at a given time. 99 | priority: priority of the message (optional). Must be one of ``high`` or ``normal``. 100 | ttl: the time-to-live duration of the message (optional) represent as string. For example: 7200s 101 | restricted_package_name: the package name of the application where the registration tokens must match in order to 102 | receive the message (optional). 103 | data: a dictionary of data fields (optional). All keys and values in the dictionary must be strings. 104 | notification: a ``messages.AndroidNotification`` to be included in the message (optional). 105 | """ 106 | 107 | collapse_key: t.Optional[str] = None 108 | priority: t.Optional[str] = None 109 | ttl: t.Optional[str] = None 110 | restricted_package_name: t.Optional[str] = None 111 | data: t.Dict[str, str] = field(default_factory=dict) 112 | notification: t.Optional[AndroidNotification] = field(default=None) 113 | 114 | 115 | @dataclass 116 | class ApsAlert: 117 | """ 118 | An alert that can be included in ``message.Aps``. 119 | 120 | Attributes: 121 | title: title of the alert (optional). If specified, overrides the title set via ``messages.Notification``. 122 | body: body of the alert (optional). If specified, overrides the body set via ``messages.Notification``. 123 | loc_key: key of the body string in the app's string resources to use to localize the body text (optional). 124 | loc_args: a list of resource keys that will be used in place of the format specifiers in ``loc_key`` (optional). 125 | title_loc_key: key of the title string in the app's string resources to use to localize the title text (optional). 126 | title_loc_args: a list of resource keys that will be used in place of the format specifiers in ``title_loc_key`` 127 | (optional). 128 | action_loc_key: key of the text in the app's string resources to use to localize the action button text (optional). 129 | launch_image: image for the notification action (optional). 130 | """ 131 | 132 | title: t.Optional[str] = None 133 | body: t.Optional[str] = None 134 | loc_key: t.Optional[str] = None 135 | loc_args: t.List[str] = field(default_factory=list) 136 | title_loc_key: t.Optional[str] = None 137 | title_loc_args: t.List[str] = field(default_factory=list) 138 | action_loc_key: t.Optional[str] = None 139 | launch_image: t.Optional[str] = None 140 | 141 | 142 | @dataclass 143 | class Aps: 144 | """ 145 | Aps dictionary to be included in an APNS payload. 146 | 147 | Attributes: 148 | alert: a string or a ``messages.ApsAlert`` instance (optional). 149 | badge: a number representing the badge to be displayed with the message (optional). 150 | sound: name of the sound file to be played with the message (optional). 151 | content_available: a boolean indicating whether to configure a background update notification (optional). 152 | category: string identifier representing the message type (optional). 153 | thread_id: an app-specific string identifier for grouping messages (optional). 154 | mutable_content: a boolean indicating whether to support mutating notifications at the client using app extensions 155 | (optional). 156 | custom_data: a dictionary of custom key-value pairs to be included in the Aps dictionary (optional). These 157 | attributes will be then re-assembled according to the format allowed by APNS. Below you may find details: 158 | 159 | In addition to the Apple-defined keys, custom keys may be added to payload to deliver small amounts of data 160 | to app, notification service app extension, or notification content app extension. Custom keys must 161 | have values with primitive types, such as dictionary, array, string, number, or Boolean. Custom keys are 162 | available in the ``userInfo`` dictionary of the ``UNNotificationContent`` object delivered to app. 163 | 164 | In a nutshell custom keys should be incorporated on the same level as ``apns`` attribute. 165 | """ 166 | 167 | alert: t.Union[str, ApsAlert, None] = None 168 | badge: t.Optional[int] = None 169 | sound: t.Optional[str] = None 170 | content_available: t.Optional[bool] = None 171 | category: t.Optional[str] = None 172 | thread_id: t.Optional[str] = None 173 | mutable_content: t.Optional[bool] = None 174 | custom_data: t.Dict[str, str] = field(default_factory=dict) 175 | 176 | 177 | @dataclass 178 | class APNSPayload: 179 | """ 180 | Payload of an APNS message. 181 | 182 | Attributes: 183 | aps: a ``messages.Aps`` instance to be included in the payload. 184 | """ 185 | 186 | aps: t.Optional[Aps] = field(default=None) 187 | 188 | 189 | @dataclass 190 | class APNSConfig: 191 | """ 192 | APNS-specific options that can be included in a message. 193 | 194 | Refer to APNS Documentation: https://developer.apple.com/library/content/documentation\ 195 | /NetworkingInternet/Conceptual/RemoteNotificationsPG/CommunicatingwithAPNs.html for more information. 196 | 197 | Attributes: 198 | headers: a dictionary of headers (optional). 199 | payload: a ``messages.APNSPayload`` to be included in the message (optional). 200 | """ 201 | 202 | headers: t.Dict[str, str] = field(default_factory=dict) 203 | payload: t.Optional[APNSPayload] = field(default=None) 204 | 205 | 206 | @dataclass 207 | class Notification: 208 | """ 209 | A notification that can be included in a message. 210 | 211 | Attributes: 212 | title: title of the notification (optional). 213 | body: body of the notification (optional). 214 | image: image url of the notification (optional) 215 | """ 216 | 217 | title: t.Optional[str] = None 218 | body: t.Optional[str] = None 219 | image: t.Optional[str] = None 220 | 221 | 222 | @dataclass 223 | class WebpushFCMOptions: 224 | """ 225 | Options for features provided by the FCM SDK for Web. 226 | 227 | Arguments: 228 | link: The link to open when the user clicks on the notification. Must be an HTTPS URL (optional). 229 | """ 230 | 231 | link: str 232 | 233 | 234 | @dataclass 235 | class WebpushNotificationAction: 236 | """ 237 | An action available to the users when the notification is presented. 238 | 239 | Arguments: 240 | action: Action string. 241 | title: Title string. 242 | icon: Icon URL for the action (optional). 243 | """ 244 | 245 | action: t.Optional[str] 246 | title: t.Optional[str] 247 | icon: t.Optional[str] = None 248 | 249 | 250 | @dataclass 251 | class WebpushNotification: 252 | """ 253 | Webpush-specific notification parameters. 254 | 255 | Arguments: 256 | title: title of the notification (optional). If specified, overrides the title set via ``messages.Notification``. 257 | body: body of the notification (optional). If specified, overrides the body set via ``messages.Notification``. 258 | icon: icon URL of the notification (optional). 259 | actions: a list of ``messages.WebpushNotificationAction`` instances (optional). 260 | badge: URL of the image used to represent the notification when there is not enough space to display the 261 | notification itself (optional). 262 | data: any arbitrary JSON data that should be associated with the notification (optional). 263 | direction: the direction in which to display the notification (optional). Must be either 'auto', 'ltr' or 'rtl'. 264 | image: the URL of an image to be displayed in the notification (optional). 265 | language: notification language (optional). 266 | renotify: a boolean indicating whether the user should be notified after a new notification replaces 267 | an old one (optional). 268 | require_interaction: a boolean indicating whether a notification should remain active until the user clicks or 269 | dismisses it, rather than closing automatically (optional). 270 | silent: ``True`` to indicate that the notification should be silent (optional). 271 | tag: an identifying tag on the notification (optional). 272 | timestamp_millis: a timestamp value in milliseconds on the notification (optional). 273 | vibrate: a vibration pattern for the device's vibration hardware to emit when the notification fires (optional). 274 | The pattern is specified as an integer array. 275 | custom_data: a dict of custom key-value pairs to be included in the notification (optional) 276 | """ 277 | 278 | title: t.Optional[str] = None 279 | body: t.Optional[str] = None 280 | icon: t.Optional[str] = None 281 | actions: t.List[WebpushNotificationAction] = field(default_factory=list) 282 | badge: t.Optional[str] = None 283 | data: t.Dict[str, str] = field(default_factory=dict) 284 | direction: t.Optional[str] = None 285 | image: t.Optional[str] = None 286 | language: t.Optional[str] = None 287 | renotify: t.Optional[bool] = None 288 | require_interaction: t.Optional[bool] = None 289 | silent: t.Optional[bool] = None 290 | tag: t.Optional[str] = None 291 | timestamp_millis: t.Optional[int] = None 292 | vibrate: t.Optional[str] = None 293 | custom_data: t.Dict[str, str] = field(default_factory=dict) 294 | 295 | 296 | @dataclass 297 | class WebpushConfig: 298 | """ 299 | Webpush-specific options that can be included in a message. 300 | 301 | Attributes: 302 | headers: a dictionary of headers (optional). Refer to 303 | [Webpush protocol](https://tools.ietf.org/html/rfc8030#section-5) for supported headers. 304 | data: A dictionary of data fields (optional). All keys and values in the dictionary must be 305 | strings. When specified, overrides any data fields set via ``Message.data``. 306 | notification: a ``messages.WebpushNotification`` to be included in the message (optional). 307 | fcm_options: a ``messages.WebpushFCMOptions`` instance to be included in the message (optional). 308 | """ 309 | 310 | headers: t.Dict[str, str] = field(default_factory=dict) 311 | data: t.Dict[str, str] = field(default_factory=dict) 312 | notification: t.Optional[WebpushNotification] = field(default=None) 313 | fcm_options: t.Optional[WebpushFCMOptions] = field(default=None) 314 | 315 | 316 | @dataclass 317 | class FcmOptions: 318 | """ 319 | Platform independent options for features provided by the FCM SDKs 320 | Arguments: 321 | analytics_label: Label associated with the message's analytics data. 322 | """ 323 | 324 | analytics_label: str 325 | 326 | 327 | @dataclass 328 | class Message: 329 | """ 330 | A common message that can be sent via Firebase. 331 | 332 | Contains payload information as well as recipient information. In particular, the message must contain exactly one 333 | of token, topic or condition fields. 334 | 335 | Attributes: 336 | data: a dictionary of data fields (optional). All keys and values in the dictionary must be strings. 337 | notification: an instance of ``messages.Notification`` (optional). 338 | android: an instance of ``messages.AndroidConfig`` (optional). 339 | webpush: an instance of ``messages.WebpushConfig`` (optional). 340 | apns: an instance of ``messages.ApnsConfig`` (optional). 341 | token: the registration token of the device to which the message should be sent. 342 | topic: name of the Firebase topic to which the message should be sent (optional). 343 | condition: the Firebase condition to which the message should be sent (optional). 344 | fcm_options: platform independent options for features provided by the FCM SDKs. 345 | """ 346 | 347 | token: t.Optional[str] = None 348 | data: t.Dict[str, str] = field(default_factory=dict) 349 | notification: t.Optional[Notification] = field(default=None) 350 | android: t.Optional[AndroidConfig] = field(default=None) 351 | webpush: t.Optional[WebpushConfig] = field(default=None) 352 | apns: t.Optional[APNSConfig] = field(default=None) 353 | topic: t.Optional[str] = None 354 | condition: t.Optional[str] = None 355 | fcm_options: t.Optional[FcmOptions] = field(default=None) 356 | 357 | 358 | @dataclass 359 | class MulticastMessage: 360 | """ 361 | A message that can be sent to multiple tokens via Firebase. 362 | 363 | Attributes: 364 | tokens: a list of registration tokens of targeted devices. 365 | data: a dictionary of data fields (optional). All keys and values in the dictionary must be strings. 366 | notification: an instance of ``messages.Notification`` (optional). 367 | android: an instance of ``messages.AndroidConfig`` (optional). 368 | webpush: an instance of ``messages.WebpushConfig`` (optional). 369 | apns: an instance of ``messages.ApnsConfig`` (optional). 370 | fcm_options: platform independent options for features provided by the FCM SDKs. 371 | """ 372 | 373 | tokens: t.List[str] 374 | data: t.Dict[str, str] = field(default_factory=dict) 375 | notification: t.Optional[Notification] = field(default=None) 376 | android: t.Optional[AndroidConfig] = field(default=None) 377 | webpush: t.Optional[WebpushConfig] = field(default=None) 378 | apns: t.Optional[APNSConfig] = field(default=None) 379 | fcm_options: t.Optional[FcmOptions] = field(default=None) 380 | 381 | 382 | @dataclass 383 | class PushNotification: 384 | """The payload that is sent to Firebase Cloud Messaging. 385 | 386 | Attributes: 387 | message: an instance of ``messages.Message`` or ``messages.MulticastMessage``. 388 | validate_only: a boolean indicating whether to run the operation in dry run mode (optional). 389 | """ 390 | 391 | message: Message 392 | validate_only: t.Optional[bool] = field(default=False) 393 | 394 | 395 | class FCMResponse: 396 | """The response received from an individual batched request to the FCM API. 397 | 398 | The interface of this object is compatible with SendResponse object of 399 | the Google's firebase-admin-python package. 400 | """ 401 | 402 | def __init__( 403 | self, fcm_response: t.Optional[t.Dict[str, str]] = None, exception: t.Optional[AsyncFirebaseError] = None 404 | ): 405 | """Inits FCMResponse object. 406 | 407 | :param fcm_response: a dictionary with the data that FCM returns as a payload 408 | :param exception: an exception that may happen when communicating with FCM 409 | """ 410 | self.message_id = fcm_response.get("name") if fcm_response else None 411 | self.exception = exception 412 | 413 | @property 414 | def success(self) -> bool: 415 | """A boolean indicating if the request was successful.""" 416 | return self.message_id is not None and not self.exception 417 | 418 | 419 | class FCMBatchResponse: 420 | """The response received from a batch request to the FCM API. 421 | 422 | The interface of this object is compatible with BatchResponse object of 423 | the Google's firebase-admin-python package. 424 | """ 425 | 426 | def __init__(self, responses: t.List[FCMResponse]): 427 | """Inits FCMBatchResponse. 428 | 429 | :param responses: a list of FCMResponse objects 430 | """ 431 | self._responses = responses 432 | self._success_count = len([resp for resp in responses if resp.success]) 433 | 434 | @property 435 | def responses(self): 436 | """A list of ``FCMResponse`` objects (possibly empty).""" 437 | return self._responses 438 | 439 | @property 440 | def success_count(self): 441 | return self._success_count 442 | 443 | @property 444 | def failure_count(self): 445 | return len(self.responses) - self.success_count 446 | 447 | 448 | class TopicManagementErrorInfo: 449 | """An error encountered when performing a topic management operation.""" 450 | 451 | def __init__(self, index, reason): 452 | self._index = index 453 | self._reason = reason 454 | 455 | @property 456 | def index(self): 457 | """Index of the registration token to which this error is related to.""" 458 | return self._index 459 | 460 | @property 461 | def reason(self): 462 | """String describing the nature of the error.""" 463 | return self._reason 464 | 465 | 466 | class TopicManagementResponse: 467 | """The response received from a topic management operation.""" 468 | 469 | def __init__(self, resp: t.Optional[httpx.Response] = None, exception: t.Optional[AsyncFirebaseError] = None): 470 | self.exception = exception 471 | self._success_count = 0 472 | self._failure_count = 0 473 | self._errors: t.List[TopicManagementErrorInfo] = [] 474 | 475 | if resp: 476 | self._handle_response(resp) 477 | 478 | def _handle_response(self, resp: httpx.Response): 479 | response = resp.json() 480 | results = response.get("results") 481 | if not results: 482 | raise ValueError("Unexpected topic management response: {0}.".format(resp)) 483 | 484 | for index, result in enumerate(results): 485 | if "error" in result: 486 | self._failure_count += 1 487 | self._errors.append(TopicManagementErrorInfo(index, result["error"])) 488 | else: 489 | self._success_count += 1 490 | 491 | @property 492 | def success_count(self): 493 | """Number of tokens that were successfully subscribed or unsubscribed.""" 494 | return self._success_count 495 | 496 | @property 497 | def failure_count(self): 498 | """Number of tokens that could not be subscribed or unsubscribed due to errors.""" 499 | return self._failure_count 500 | 501 | @property 502 | def errors(self): 503 | """A list of ``messaging.ErrorInfo`` objects (possibly empty).""" 504 | return self._errors 505 | -------------------------------------------------------------------------------- /async_firebase/client.py: -------------------------------------------------------------------------------- 1 | """ 2 | The module houses client to communicate with FCM - Firebase Cloud Messaging (Android, iOS and Web). 3 | 4 | Documentation for google-auth package https://google-auth.readthedocs.io/en/latest/user-guide.html that is used 5 | to authorize request which is being made to Firebase. 6 | """ 7 | 8 | import asyncio 9 | import collections 10 | import json 11 | import logging 12 | import typing as t 13 | import warnings 14 | from dataclasses import replace 15 | from datetime import datetime, timedelta, timezone 16 | from email.mime.multipart import MIMEMultipart 17 | from email.mime.nonmultipart import MIMENonMultipart 18 | from urllib.parse import urljoin 19 | 20 | import httpx 21 | 22 | from async_firebase.base import AsyncClientBase, RequestLimits, RequestTimeout # noqa: F401 23 | from async_firebase.encoders import aps_encoder 24 | from async_firebase.messages import ( 25 | AndroidConfig, 26 | AndroidNotification, 27 | APNSConfig, 28 | APNSPayload, 29 | Aps, 30 | ApsAlert, 31 | FCMBatchResponse, 32 | FCMResponse, 33 | Message, 34 | MulticastMessage, 35 | NotificationProxy, 36 | PushNotification, 37 | TopicManagementResponse, 38 | Visibility, 39 | WebpushConfig, 40 | WebpushFCMOptions, 41 | WebpushNotification, 42 | WebpushNotificationAction, 43 | ) 44 | from async_firebase.utils import ( 45 | FCMBatchResponseHandler, 46 | FCMResponseHandler, 47 | TopicManagementResponseHandler, 48 | cleanup_firebase_message, 49 | serialize_mime_message, 50 | ) 51 | 52 | 53 | DEFAULT_TTL = 604800 54 | BATCH_MAX_MESSAGES = MULTICAST_MESSAGE_MAX_DEVICE_TOKENS = 500 55 | 56 | 57 | class AsyncFirebaseClient(AsyncClientBase): 58 | """Async wrapper for Firebase Cloud Messaging. 59 | 60 | The AsyncFirebaseClient relies on Service Account to enable us making a request. To get more about Service Account 61 | please refer to https://firebase.google.com/support/guides/service-accounts 62 | """ 63 | 64 | @staticmethod 65 | def assemble_push_notification( 66 | *, 67 | apns_config: t.Optional[APNSConfig], 68 | message: Message, 69 | dry_run: bool, 70 | ) -> t.Dict[str, t.Any]: 71 | """ 72 | Assemble ``messages.PushNotification`` object properly. 73 | 74 | :param apns_config: instance of ``messages.APNSConfig`` 75 | :param dry_run: A boolean indicating whether to run the operation in dry run mode 76 | :param message: an instance of ``messages.Message`` 77 | :return: dictionary with push notification data ready to send 78 | """ 79 | has_apns_config = True if apns_config and apns_config.payload else False 80 | if has_apns_config: 81 | # avoid mutation of active message 82 | message.apns = replace(message.apns) # type: ignore 83 | message.apns.payload = aps_encoder(apns_config.payload.aps) # type: ignore 84 | 85 | push_notification: t.Dict[str, t.Any] = cleanup_firebase_message( 86 | PushNotification(message=message, validate_only=dry_run) 87 | ) 88 | if len(push_notification["message"]) == 1: 89 | logging.warning("No data has been provided to construct push notification payload") 90 | raise ValueError("``messages.PushNotification`` cannot be assembled as data has not been provided") 91 | return push_notification 92 | 93 | @staticmethod 94 | def build_android_config( # pylint: disable=too-many-locals 95 | *, 96 | priority: str, 97 | ttl: t.Union[int, timedelta] = DEFAULT_TTL, 98 | collapse_key: t.Optional[str] = None, 99 | restricted_package_name: t.Optional[str] = None, 100 | data: t.Optional[t.Dict[str, t.Any]] = None, 101 | title: t.Optional[str] = None, 102 | body: t.Optional[str] = None, 103 | icon: t.Optional[str] = None, 104 | color: t.Optional[str] = None, 105 | sound: t.Optional[str] = None, 106 | tag: t.Optional[str] = None, 107 | click_action: t.Optional[str] = None, 108 | body_loc_key: t.Optional[str] = None, 109 | body_loc_args: t.Optional[t.List[str]] = None, 110 | title_loc_key: t.Optional[str] = None, 111 | title_loc_args: t.Optional[t.List[str]] = None, 112 | channel_id: t.Optional[str] = None, 113 | notification_count: t.Optional[int] = None, 114 | visibility: Visibility = Visibility.PRIVATE, 115 | proxy: t.Optional[NotificationProxy] = None, 116 | ) -> AndroidConfig: 117 | """ 118 | Constructs AndroidConfig that will be used to customize the messages that are sent to Android device. 119 | 120 | :param priority: sets the priority of the message. Valid values are "normal" and "high." 121 | :param ttl: this parameter specifies how long (in seconds) the message should be kept in Firebase storage if the 122 | device is offline. The maximum time to live supported is 4 weeks, and the default value is 4 weeks. 123 | :param collapse_key: this parameter identifies a group of messages that can be collapsed, so that only the last 124 | message gets sent when delivery can be resumed. 125 | :param restricted_package_name: The package name of the application where the registration tokens must match in 126 | order to receive the message (optional). 127 | :param data: A dictionary of data fields (optional). All keys and values in the dictionary must be strings. 128 | :param title: Title of the notification (optional). 129 | :param body: Body of the notification (optional). 130 | :param icon: Icon of the notification (optional). 131 | :param color: Color of the notification icon expressed in ``#rrggbb`` form (optional). 132 | :param sound: Sound to be played when the device receives the notification (optional). This is usually the file 133 | name of the sound resource. 134 | :param tag: Tag of the notification (optional). This is an identifier used to replace existing notifications in 135 | the notification drawer. If not specified, each request creates a new notification. 136 | :param click_action: The action associated with a user click on the notification (optional). If specified, an 137 | activity with a matching intent filter is launched when a user clicks on the notification. 138 | :param body_loc_key: Key of the body string in the app's string resources to use to localize the 139 | body text (optional). 140 | :param body_loc_args: A list of resource keys that will be used in place of the format specifiers 141 | in ``body_loc_key`` (optional). 142 | :param title_loc_key: Key of the title string in the app's string resources to use to localize the 143 | title text (optional). 144 | :param title_loc_args: A list of resource keys that will be used in place of the format specifiers 145 | in ``title_loc_key`` (optional). 146 | :param channel_id: Notification channel id, used by android to allow user to configure notification display 147 | rules on per-channel basis (optional). 148 | :param notification_count: The number of items in notification. May be displayed as a badge count for launchers 149 | that support badging. If zero or unspecified, systems that support badging use the default, which is to 150 | increment a number displayed on the long-press menu each time a new notification arrives (optional). 151 | :param visibility: set the visibility of the notification. The default level, VISIBILITY_PRIVATE. 152 | :param proxy: set the proxy behaviour. The default behaviour is set to None. 153 | :return: an instance of ``messages.AndroidConfig`` to be included in the resulting payload. 154 | """ 155 | android_config = AndroidConfig( 156 | collapse_key=collapse_key, 157 | priority=priority, 158 | ttl=f"{int(ttl.total_seconds()) if isinstance(ttl, timedelta) else ttl}s", 159 | restricted_package_name=restricted_package_name, 160 | data={str(key): "null" if value is None else str(value) for key, value in data.items()} if data else {}, 161 | notification=AndroidNotification( 162 | title=title, 163 | body=body, 164 | icon=icon, 165 | color=color, 166 | sound=sound, 167 | tag=tag, 168 | click_action=click_action, 169 | body_loc_key=body_loc_key, 170 | body_loc_args=body_loc_args or [], 171 | title_loc_key=title_loc_key, 172 | title_loc_args=title_loc_args or [], 173 | channel_id=channel_id, 174 | notification_count=notification_count, 175 | visibility=visibility, 176 | proxy=proxy, 177 | ), 178 | ) 179 | 180 | return android_config 181 | 182 | @staticmethod 183 | def build_apns_config( # pylint: disable=too-many-locals 184 | *, 185 | priority: str, 186 | ttl: int = DEFAULT_TTL, 187 | apns_topic: t.Optional[str] = None, 188 | collapse_key: t.Optional[str] = None, 189 | title: t.Optional[str] = None, 190 | alert: t.Optional[str] = None, 191 | badge: t.Optional[int] = None, 192 | sound: t.Optional[str] = None, 193 | content_available: bool = False, 194 | category: t.Optional[str] = None, 195 | thread_id: t.Optional[str] = None, 196 | mutable_content: bool = True, 197 | custom_data: t.Optional[t.Dict[str, t.Any]] = None, 198 | loc_key: t.Optional[str] = None, 199 | loc_args: t.Optional[t.List[str]] = None, 200 | title_loc_key: t.Optional[str] = None, 201 | title_loc_args: t.Optional[t.List[str]] = None, 202 | action_loc_key: t.Optional[str] = None, 203 | launch_image: t.Optional[str] = None, 204 | ) -> APNSConfig: 205 | """ 206 | Constructs APNSConfig that will be used to customize the messages that are sent to iOS device. 207 | 208 | :param priority: sets the priority of the message. On iOS, these correspond to APNs priorities 5 and 10. 209 | :param ttl: this parameter specifies how long (in seconds) the message should be kept in Firebase storage if the 210 | device is offline. The maximum time to live supported is 4 weeks, and the default value is 4 weeks. 211 | :param apns_topic: the topic for the notification. In general, the topic is your app’s bundle ID/app ID. 212 | It can have a suffix based on the type of push notification. 213 | :param collapse_key: this parameter identifies a group of messages that can be collapsed, so that only the last 214 | message gets sent when delivery can be resumed. 215 | :param title: title of the alert (optional). If specified, overrides the title set via ``messages.Notification`` 216 | :param alert: a string or a ``messages.ApsAlert`` instance (optional). 217 | :param badge: the value of the badge on the home screen app icon. If not specified, the badge is not changed. 218 | If set to 0, the badge is removed. 219 | :param sound: name of the sound file to be played with the message (optional). 220 | :param content_available: A boolean indicating whether to configure a background update notification (optional). 221 | :param category: string identifier representing the message type (optional). 222 | :param thread_id: an app-specific string identifier for grouping messages (optional). 223 | :param mutable_content: A boolean indicating whether to support mutating notifications at the client using app 224 | extensions (optional). 225 | :param custom_data: A dict of custom key-value pairs to be included in the Aps dictionary (optional). 226 | :param loc_key: key of the body string in the app's string resources to use to localize the body text 227 | (optional). 228 | :param loc_args: a list of resource keys that will be used in place of the format specifiers in ``loc_key`` 229 | (optional). 230 | :param title_loc_key: key of the title string in the app's string resources to use to localize the title text 231 | (optional). 232 | :param title_loc_args: a list of resource keys that will be used in place of the format specifiers in 233 | ``title_loc_key`` (optional). 234 | :param action_loc_key: key of the text in the app's string resources to use to localize the action button text 235 | (optional). 236 | :param launch_image: image for the notification action (optional). 237 | :return: an instance of ``messages.APNSConfig`` to included in the resulting payload. 238 | """ 239 | 240 | apns_headers = { 241 | "apns-expiration": str(int(datetime.now(timezone.utc).timestamp()) + ttl), 242 | "apns-priority": str(10 if priority == "high" else 5), 243 | } 244 | if apns_topic: 245 | apns_headers["apns-topic"] = apns_topic 246 | if collapse_key: 247 | apns_headers["apns-collapse-id"] = str(collapse_key) 248 | 249 | apns_config = APNSConfig( 250 | headers=apns_headers, 251 | payload=APNSPayload( 252 | aps=Aps( 253 | alert=ApsAlert( 254 | title=title, 255 | body=alert, 256 | loc_key=loc_key, 257 | loc_args=loc_args or [], 258 | title_loc_key=title_loc_key, 259 | title_loc_args=title_loc_args or [], 260 | action_loc_key=action_loc_key, 261 | launch_image=launch_image, 262 | ), 263 | badge=badge, 264 | sound="default" if alert and sound is None else sound, 265 | category=category, 266 | thread_id=thread_id, 267 | mutable_content=mutable_content, 268 | custom_data=custom_data or {}, 269 | content_available=True if content_available else None, 270 | ), 271 | ), 272 | ) 273 | 274 | return apns_config 275 | 276 | @staticmethod 277 | def build_webpush_config( # pylint: disable=too-many-locals 278 | *, 279 | data: t.Dict[str, str], 280 | headers: t.Optional[t.Dict[str, str]] = None, 281 | title: t.Optional[str] = None, 282 | body: t.Optional[str] = None, 283 | icon: t.Optional[str] = None, 284 | actions: t.Optional[t.List[WebpushNotificationAction]] = None, 285 | badge: t.Optional[str] = None, 286 | direction: t.Optional[str] = "auto", 287 | image: t.Optional[str] = None, 288 | language: t.Optional[str] = None, 289 | renotify: t.Optional[bool] = False, 290 | require_interaction: t.Optional[bool] = None, 291 | silent: t.Optional[bool] = False, 292 | tag: t.Optional[str] = None, 293 | timestamp_millis: t.Optional[int] = None, 294 | vibrate: t.Optional[str] = None, 295 | custom_data: t.Optional[t.Dict[str, str]] = None, 296 | link: t.Optional[str] = None, 297 | ) -> WebpushConfig: 298 | """ 299 | Constructs WebpushConfig that will be used to customize the messages that are sent user agents. 300 | 301 | :param data: A dictionary of data fields (optional). All keys and values in the dictionary must be strings. 302 | :param headers: a dictionary of headers (optional). 303 | :param title: title of the notification (optional). 304 | :param body: body of the notification (optional). 305 | :param icon: icon URL of the notification (optional). 306 | :param actions: a list of ``messages.WebpushNotificationAction`` instances (optional). 307 | :param badge: URL of the image used to represent the notification when there is not enough space to display the 308 | notification itself (optional). 309 | :param data: any arbitrary JSON data that should be associated with the notification (optional). 310 | :param direction: the direction in which to display the notification (optional). Must be either 'auto', 'ltr' 311 | or 'rtl'. 312 | :param image: the URL of an image to be displayed in the notification (optional). 313 | :param language: notification language (optional). 314 | :param renotify: a boolean indicating whether the user should be notified after a new notification replaces 315 | an old one (optional). 316 | :param require_interaction: a boolean indicating whether a notification should remain active until the user 317 | clicks or dismisses it, rather than closing automatically (optional). 318 | :param silent: ``True`` to indicate that the notification should be silent (optional). 319 | :param tag: an identifying tag on the notification (optional). 320 | :param timestamp_millis: a timestamp value in milliseconds on the notification (optional). 321 | :param vibrate: a vibration pattern for the device's vibration hardware to emit when the notification 322 | fires (optional). The pattern is specified as an integer array. 323 | :param custom_data: a dict of custom key-value pairs to be included in the notification (optional) 324 | :param link: The link to open when the user clicks on the notification. Must be an HTTPS URL (optional). 325 | :return: an instance of ``messages.WebpushConfig`` to included in the resulting payload. 326 | """ 327 | return WebpushConfig( 328 | data=data, 329 | headers=headers or {}, 330 | notification=WebpushNotification( 331 | title=title, 332 | body=body, 333 | icon=icon, 334 | actions=actions or [], 335 | badge=badge, 336 | direction=direction, 337 | image=image, 338 | language=language, 339 | renotify=renotify, 340 | require_interaction=require_interaction, 341 | silent=silent, 342 | tag=tag, 343 | timestamp_millis=timestamp_millis, 344 | vibrate=vibrate, 345 | custom_data=custom_data or {}, 346 | ), 347 | fcm_options=WebpushFCMOptions(link=link) if link else None, 348 | ) 349 | 350 | async def send(self, message: Message, *, dry_run: bool = False) -> FCMResponse: 351 | """ 352 | Send push notification. 353 | 354 | :param message: the message that has to be sent. 355 | :param dry_run: indicating whether to run the operation in dry run mode (optional). Flag for testing the request 356 | without actually delivering the message. Default to ``False``. 357 | 358 | :raises: 359 | 360 | ValueError if ``messages.PushNotification`` payload cannot be assembled 361 | 362 | :return: instance of ``messages.FCMResponse`` 363 | 364 | Example of raw response: 365 | 366 | success:: 367 | 368 | { 369 | 'name': 'projects/mobile-app/messages/0:1612788010922733%7606eb247606eb24' 370 | } 371 | 372 | failure:: 373 | 374 | { 375 | 'error': { 376 | 'code': 400, 377 | 'details': [ 378 | { 379 | '@type': 'type.googleapis.com/google.rpc.BadRequest', 380 | 'fieldViolations': [ 381 | { 382 | 'description': 'Value type for APS key [badge] is a number.', 383 | 'field': 'message.apns.payload.aps.badge' 384 | } 385 | ] 386 | }, 387 | { 388 | '@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError', 389 | 'errorCode': 'INVALID_ARGUMENT' 390 | } 391 | ], 392 | 'message': 'Value type for APS key [badge] is a number.', 393 | 'status': 'INVALID_ARGUMENT' 394 | } 395 | } 396 | """ 397 | push_notification = self.assemble_push_notification(apns_config=message.apns, dry_run=dry_run, message=message) 398 | 399 | response = await self.send_request( 400 | uri=self.FCM_ENDPOINT.format(project_id=self._credentials.project_id), # type: ignore 401 | json_payload=push_notification, 402 | response_handler=FCMResponseHandler(), 403 | ) 404 | if not isinstance(response, FCMResponse): 405 | raise ValueError("Wrong return type, perhaps because of a response handler misuse.") 406 | return response 407 | 408 | async def send_multicast( 409 | self, 410 | multicast_message: MulticastMessage, 411 | *, 412 | dry_run: bool = False, 413 | ) -> FCMBatchResponse: 414 | """ 415 | Send Multicast push notification. 416 | 417 | :param multicast_message: multicast message to send targeted notifications to a set of instances of app. 418 | May contain up to 500 device tokens. 419 | :param dry_run: indicating whether to run the operation in dry run mode (optional). Flag for testing the request 420 | without actually delivering the message. Default to ``False``. 421 | 422 | :raises: 423 | 424 | ValueError if ``messages.PushNotification`` payload cannot be assembled 425 | ValueError if ``messages.MulticastMessage`` contains more than MULTICAST_MESSAGE_MAX_DEVICE_TOKENS 426 | 427 | :return: instance of ``messages.FCMBatchResponse`` 428 | """ 429 | warnings.warn( 430 | "send_multicast is going to be deprecated, please use send_each_for_multicast instead", DeprecationWarning 431 | ) 432 | 433 | if len(multicast_message.tokens) > MULTICAST_MESSAGE_MAX_DEVICE_TOKENS: 434 | raise ValueError( 435 | f"A single ``messages.MulticastMessage`` may contain up to {MULTICAST_MESSAGE_MAX_DEVICE_TOKENS} " 436 | "device tokens." 437 | ) 438 | 439 | messages = [ 440 | Message( 441 | token=token, 442 | data=multicast_message.data, 443 | notification=multicast_message.notification, 444 | android=multicast_message.android, 445 | webpush=multicast_message.webpush, 446 | apns=multicast_message.apns, 447 | fcm_options=multicast_message.fcm_options, 448 | ) 449 | for token in multicast_message.tokens 450 | ] 451 | 452 | return await self.send_all(messages, dry_run=dry_run) 453 | 454 | async def send_all( 455 | self, 456 | messages: t.Union[t.List[Message], t.Tuple[Message]], 457 | *, 458 | dry_run: bool = False, 459 | ) -> FCMBatchResponse: 460 | """ 461 | Send the given messages to FCM in a single batch. 462 | 463 | :param messages: the list of messages to send. 464 | :param dry_run: indicating whether to run the operation in dry run mode (optional). Flag for testing the request 465 | without actually delivering the message. Default to ``False``. 466 | :returns: instance of ``messages.FCMBatchResponse`` 467 | """ 468 | warnings.warn("send_all is going to be deprecated, please use send_each instead", DeprecationWarning) 469 | 470 | if len(messages) > BATCH_MAX_MESSAGES: 471 | raise ValueError(f"A list of messages must not contain more than {BATCH_MAX_MESSAGES} elements") 472 | 473 | multipart_message = MIMEMultipart("mixed") 474 | # Message should not write out it's own headers. 475 | setattr(multipart_message, "_write_headers", lambda self: None) 476 | 477 | for message in messages: 478 | msg = MIMENonMultipart("application", "http") 479 | msg["Content-Transfer-Encoding"] = "binary" 480 | msg["Content-ID"] = self.get_request_id() 481 | push_notification = self.assemble_push_notification( 482 | apns_config=message.apns, 483 | dry_run=dry_run, 484 | message=message, 485 | ) 486 | body = self.serialize_batch_request( 487 | httpx.Request( 488 | method="POST", 489 | url=urljoin( 490 | self.BASE_URL, self.FCM_ENDPOINT.format(project_id=self._credentials.project_id) # type: ignore 491 | ), 492 | headers=await self.prepare_headers(), 493 | content=json.dumps(push_notification), 494 | ) 495 | ) 496 | msg.set_payload(body) 497 | multipart_message.attach(msg) 498 | 499 | # encode the body: note that we can't use `as_string`, because it plays games with `From ` lines. 500 | body = serialize_mime_message(multipart_message, mangle_from=False) 501 | 502 | batch_response = await self.send_request( 503 | uri=self.FCM_BATCH_ENDPOINT, 504 | content=body, 505 | headers={"Content-Type": f"multipart/mixed; boundary={multipart_message.get_boundary()}"}, 506 | response_handler=FCMBatchResponseHandler(), 507 | ) 508 | if not isinstance(batch_response, FCMBatchResponse): 509 | raise ValueError("Wrong return type, perhaps because of a response handler misuse.") 510 | return batch_response 511 | 512 | async def send_each( 513 | self, 514 | messages: t.Union[t.List[Message], t.Tuple[Message]], 515 | *, 516 | dry_run: bool = False, 517 | ) -> FCMBatchResponse: 518 | if len(messages) > BATCH_MAX_MESSAGES: 519 | raise ValueError(f"Can not send more than {BATCH_MAX_MESSAGES} messages in a single batch") 520 | 521 | push_notifications = [ 522 | self.assemble_push_notification(apns_config=message.apns, dry_run=dry_run, message=message) 523 | for message in messages 524 | ] 525 | 526 | request_tasks: t.Collection[collections.abc.Awaitable] = [ 527 | self.send_request( 528 | uri=self.FCM_ENDPOINT.format(project_id=self._credentials.project_id), # type: ignore 529 | json_payload=push_notification, 530 | response_handler=FCMResponseHandler(), 531 | ) 532 | for push_notification in push_notifications 533 | ] 534 | fcm_responses = await asyncio.gather(*request_tasks) 535 | return FCMBatchResponse(responses=fcm_responses) 536 | 537 | async def send_each_for_multicast( 538 | self, 539 | multicast_message: MulticastMessage, 540 | *, 541 | dry_run: bool = False, 542 | ) -> FCMBatchResponse: 543 | if len(multicast_message.tokens) > MULTICAST_MESSAGE_MAX_DEVICE_TOKENS: 544 | raise ValueError( 545 | f"A single ``messages.MulticastMessage`` may contain up to {MULTICAST_MESSAGE_MAX_DEVICE_TOKENS} " 546 | "device tokens." 547 | ) 548 | 549 | messages = [ 550 | Message( 551 | token=token, 552 | data=multicast_message.data, 553 | notification=multicast_message.notification, 554 | android=multicast_message.android, 555 | webpush=multicast_message.webpush, 556 | apns=multicast_message.apns, 557 | fcm_options=multicast_message.fcm_options, 558 | ) 559 | for token in multicast_message.tokens 560 | ] 561 | 562 | return await self.send_each(messages, dry_run=dry_run) 563 | 564 | async def _make_topic_management_request( 565 | self, device_tokens: t.List[str], topic_name: str, action: str 566 | ) -> TopicManagementResponse: 567 | payload = { 568 | "to": f"/topics/{topic_name}", 569 | "registration_tokens": device_tokens, 570 | } 571 | response = await self.send_iid_request( 572 | uri=action, 573 | json_payload=payload, 574 | response_handler=TopicManagementResponseHandler(), 575 | ) 576 | return response 577 | 578 | async def subscribe_devices_to_topic(self, device_tokens: t.List[str], topic_name: str) -> TopicManagementResponse: 579 | """ 580 | Subscribes devices to the topic. 581 | 582 | :param device_tokens: devices ids to be subscribed. 583 | :param topic_name: name of the topic. 584 | :returns: Instance of messages.TopicManagementResponse. 585 | """ 586 | return await self._make_topic_management_request( 587 | device_tokens=device_tokens, topic_name=topic_name, action=self.TOPIC_ADD_ACTION 588 | ) 589 | 590 | async def unsubscribe_devices_from_topic( 591 | self, device_tokens: t.List[str], topic_name: str 592 | ) -> TopicManagementResponse: 593 | """ 594 | Unsubscribes devices from the topic. 595 | 596 | :param device_tokens: devices ids to be unsubscribed. 597 | :param topic_name: name of the topic. 598 | :returns: Instance of messages.TopicManagementResponse. 599 | """ 600 | return await self._make_topic_management_request( 601 | device_tokens=device_tokens, topic_name=topic_name, action=self.TOPIC_REMOVE_ACTION 602 | ) 603 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | import json 2 | import uuid 3 | from datetime import datetime 4 | from unittest import mock 5 | 6 | import pkg_resources 7 | import pytest 8 | from google.oauth2 import service_account 9 | from pytest_httpx import HTTPXMock 10 | 11 | from async_firebase.client import AsyncFirebaseClient 12 | from async_firebase.errors import InternalError 13 | from async_firebase.messages import ( 14 | AndroidConfig, 15 | AndroidNotification, 16 | APNSConfig, 17 | APNSPayload, 18 | Aps, 19 | ApsAlert, 20 | FCMBatchResponse, 21 | FCMResponse, 22 | TopicManagementResponse, 23 | Message, 24 | NotificationProxy, 25 | WebpushConfig, 26 | WebpushNotification, 27 | WebpushFCMOptions, 28 | MulticastMessage, 29 | TopicManagementErrorInfo, 30 | Visibility, 31 | ) 32 | from async_firebase.utils import FcmErrorCode 33 | 34 | 35 | pytestmark = pytest.mark.asyncio 36 | 37 | 38 | @pytest.fixture() 39 | def fake_async_fcm_client(): 40 | return AsyncFirebaseClient() 41 | 42 | 43 | @pytest.fixture() 44 | def fake_async_fcm_client_w_creds(fake_async_fcm_client, fake_service_account): 45 | client = AsyncFirebaseClient() 46 | client.creds_from_service_account_info(fake_service_account) 47 | return client 48 | 49 | 50 | @pytest.fixture() 51 | def fake_device_token(faker_): 52 | return faker_.bothify(text=f"{'?' * 12}:{'?' * 256}") 53 | 54 | 55 | @pytest.fixture() 56 | def fake_multi_device_tokens(faker_, request): 57 | return [faker_.bothify(text=f"{'?' * 12}:{'?' * 256}") for _ in range(request.param)] 58 | 59 | 60 | async def fake__get_access_token(): 61 | return "fake-jwt-token" 62 | 63 | 64 | @pytest.mark.parametrize( 65 | "visibility_level, exp_visibility_level, proxy, exp_proxy", 66 | ( 67 | (Visibility.PRIVATE, Visibility.PRIVATE, NotificationProxy.ALLOW, NotificationProxy.ALLOW), 68 | (Visibility.PUBLIC, Visibility.PUBLIC, NotificationProxy.DENY, NotificationProxy.DENY), 69 | ( 70 | Visibility.SECRET, 71 | Visibility.SECRET, 72 | NotificationProxy.IF_PRIORITY_LOWERED, 73 | NotificationProxy.IF_PRIORITY_LOWERED 74 | ), 75 | (Visibility.PUBLIC, Visibility.PUBLIC, None, None), 76 | ) 77 | ) 78 | def test_build_android_config(fake_async_fcm_client_w_creds, visibility_level, exp_visibility_level, proxy, exp_proxy): 79 | android_config = fake_async_fcm_client_w_creds.build_android_config( 80 | priority="high", 81 | ttl=7200, 82 | collapse_key="something", 83 | restricted_package_name="some-package", 84 | data={"key_1": "value_1", "key_2": 100, "foo": None}, 85 | color="red", 86 | sound="beep", 87 | tag="test", 88 | click_action="TOP_STORY_ACTIVITY", 89 | channel_id="some_channel_id", 90 | notification_count=7, 91 | visibility=visibility_level, 92 | proxy=proxy, 93 | ) 94 | 95 | assert android_config == AndroidConfig( 96 | **{ 97 | "priority": "high", 98 | "collapse_key": "something", 99 | "restricted_package_name": "some-package", 100 | "data": {"key_1": "value_1", "key_2": "100", "foo": "null"}, 101 | "ttl": "7200s", 102 | "notification": AndroidNotification( 103 | **{ 104 | "color": "red", 105 | "sound": "beep", 106 | "tag": "test", 107 | "click_action": "TOP_STORY_ACTIVITY", 108 | "title": None, 109 | "body": None, 110 | "icon": None, 111 | "body_loc_key": None, 112 | "body_loc_args": [], 113 | "title_loc_key": None, 114 | "title_loc_args": [], 115 | "channel_id": "some_channel_id", 116 | "notification_count": 7, 117 | "visibility": exp_visibility_level, 118 | "proxy": exp_proxy, 119 | } 120 | ), 121 | } 122 | ) 123 | 124 | 125 | def test_build_apns_config(fake_async_fcm_client_w_creds, freezer): 126 | apns_message = fake_async_fcm_client_w_creds.build_apns_config( 127 | priority="high", 128 | ttl=7200, 129 | apns_topic="test-topic", 130 | collapse_key="something", 131 | alert="alert-message", 132 | title="some-title", 133 | badge=0, 134 | ) 135 | assert apns_message == APNSConfig( 136 | **{ 137 | "headers": { 138 | "apns-expiration": str(int(datetime.utcnow().timestamp()) + 7200), 139 | "apns-priority": "10", 140 | "apns-topic": "test-topic", 141 | "apns-collapse-id": "something", 142 | }, 143 | "payload": APNSPayload( 144 | **{ 145 | "aps": Aps( 146 | **{ 147 | "alert": ApsAlert(title="some-title", body="alert-message"), 148 | "badge": 0, 149 | "sound": "default", 150 | "content_available": None, 151 | "category": None, 152 | "thread_id": None, 153 | "mutable_content": True, 154 | "custom_data": {}, 155 | } 156 | ) 157 | } 158 | ), 159 | } 160 | ) 161 | 162 | 163 | async def test_prepare_headers(fake_async_fcm_client_w_creds): 164 | fake_async_fcm_client_w_creds._get_access_token = fake__get_access_token 165 | frozen_uuid = uuid.UUID(hex="6eadf1d38633427cb83dbb9be137f48c") 166 | fake_async_fcm_client_w_creds.get_request_id = lambda: str(frozen_uuid) 167 | headers = await fake_async_fcm_client_w_creds.prepare_headers() 168 | assert headers == { 169 | "Authorization": "Bearer fake-jwt-token", 170 | "Content-Type": "application/json; UTF-8", 171 | "X-Request-Id": str(frozen_uuid), 172 | "X-GOOG-API-FORMAT-VERSION": "2", 173 | "X-FIREBASE-CLIENT": "async-firebase/{0}".format(pkg_resources.get_distribution("async-firebase").version), 174 | } 175 | 176 | 177 | async def test_push_android(fake_async_fcm_client_w_creds, fake_device_token, httpx_mock: HTTPXMock): 178 | fake_async_fcm_client_w_creds._get_access_token = fake__get_access_token 179 | creds = fake_async_fcm_client_w_creds._credentials 180 | httpx_mock.add_response( 181 | status_code=200, 182 | json={"name": f"projects/{creds.project_id}/messages/0:1612788010922733%7606eb247606eb23"}, 183 | ) 184 | android_config = fake_async_fcm_client_w_creds.build_android_config( 185 | priority="normal", 186 | ttl=604800, 187 | collapse_key="SALE", 188 | title="Sring SALE", 189 | body="Don't miss spring SALE", 190 | tag="spring-sale", 191 | notification_count=1, 192 | visibility=Visibility.PUBLIC 193 | ) 194 | message = Message(android=android_config, token=fake_device_token) 195 | response = await fake_async_fcm_client_w_creds.send(message) 196 | assert isinstance(response, FCMResponse) 197 | assert response.success 198 | assert response.message_id == "projects/fake-mobile-app/messages/0:1612788010922733%7606eb247606eb23" 199 | 200 | 201 | async def test_push_ios(fake_async_fcm_client_w_creds, fake_device_token, httpx_mock: HTTPXMock): 202 | fake_async_fcm_client_w_creds._get_access_token = fake__get_access_token 203 | creds = fake_async_fcm_client_w_creds._credentials 204 | httpx_mock.add_response( 205 | status_code=200, 206 | json={"name": f"projects/{creds.project_id}/messages/0:1612788010922733%7606eb247606eb24"}, 207 | ) 208 | apns_config = fake_async_fcm_client_w_creds.build_apns_config( 209 | priority="normal", 210 | apns_topic="test-push", 211 | collapse_key="push", 212 | badge=0, 213 | category="test-category", 214 | custom_data={"foo": "bar"}, 215 | ) 216 | message = Message(apns=apns_config, token=fake_device_token) 217 | response = await fake_async_fcm_client_w_creds.send(message) 218 | assert isinstance(response, FCMResponse) 219 | assert response.success 220 | assert response.message_id == "projects/fake-mobile-app/messages/0:1612788010922733%7606eb247606eb24" 221 | 222 | 223 | async def test_send_android_dry_run(fake_async_fcm_client_w_creds, fake_device_token, httpx_mock: HTTPXMock): 224 | fake_async_fcm_client_w_creds._get_access_token = fake__get_access_token 225 | creds = fake_async_fcm_client_w_creds._credentials 226 | httpx_mock.add_response( 227 | status_code=200, 228 | json={"name": f"projects/{creds.project_id}/messages/fake_message_id"}, 229 | ) 230 | android_config = fake_async_fcm_client_w_creds.build_android_config( 231 | priority="high", 232 | ttl=2419200, 233 | collapse_key="push", 234 | data={"discount": "15%", "key_1": "value_1", "timestamp": "2021-02-24T12:00:15"}, 235 | title="Store Changes", 236 | body="Recent store changes", 237 | visibility="PUBLIC" 238 | ) 239 | message = Message(android=android_config, token=fake_device_token) 240 | response = await fake_async_fcm_client_w_creds.send(message, dry_run=True) 241 | assert isinstance(response, FCMResponse) 242 | assert response.success 243 | assert response.message_id == "projects/fake-mobile-app/messages/fake_message_id" 244 | 245 | 246 | async def test_send_ios_dry_run(fake_async_fcm_client_w_creds, fake_device_token, httpx_mock: HTTPXMock): 247 | fake_async_fcm_client_w_creds._get_access_token = fake__get_access_token 248 | creds = fake_async_fcm_client_w_creds._credentials 249 | httpx_mock.add_response( 250 | status_code=200, 251 | json={"name": f"projects/{creds.project_id}/messages/fake_message_id"}, 252 | ) 253 | apns_config = fake_async_fcm_client_w_creds.build_apns_config( 254 | priority="normal", 255 | apns_topic="test-push", 256 | collapse_key="push", 257 | badge=0, 258 | category="test-category", 259 | custom_data={"foo": "bar"}, 260 | ) 261 | message = Message(apns=apns_config, token=fake_device_token) 262 | response = await fake_async_fcm_client_w_creds.send(message, dry_run=True) 263 | assert isinstance(response, FCMResponse) 264 | assert response.success 265 | assert response.message_id == "projects/fake-mobile-app/messages/fake_message_id" 266 | 267 | 268 | async def test_send_unauthenticated(fake_async_fcm_client_w_creds, httpx_mock: HTTPXMock): 269 | fake_async_fcm_client_w_creds._get_access_token = fake__get_access_token 270 | httpx_mock.add_response( 271 | status_code=401, 272 | json={ 273 | "error": { 274 | "code": 401, 275 | "message": "Request had invalid authentication credentials. " 276 | "Expected OAuth 2 access token, login cookie or other " 277 | "valid authentication credential. See " 278 | "https://developers.google.com/identity/sign-in/web/devconsole-project.", 279 | "status": "UNAUTHENTICATED", 280 | } 281 | }, 282 | ) 283 | apns_config = fake_async_fcm_client_w_creds.build_apns_config( 284 | priority="normal", 285 | apns_topic="test-push", 286 | collapse_key="push", 287 | badge=0, 288 | category="test-category", 289 | custom_data={"foo": "bar"}, 290 | ) 291 | message = Message(apns=apns_config, token="qwerty:ytrewq") 292 | fcm_response = await fake_async_fcm_client_w_creds.send(message) 293 | 294 | assert isinstance(fcm_response, FCMResponse) 295 | assert not fcm_response.success 296 | assert fcm_response.exception is not None 297 | assert fcm_response.exception.code == FcmErrorCode.UNAUTHENTICATED.value 298 | assert fcm_response.exception.cause.response.status_code == 401 299 | 300 | 301 | async def test_send_data_has_not_been_provided(fake_async_fcm_client_w_creds): 302 | message = Message(token="device_id:device_token") 303 | with pytest.raises(ValueError): 304 | await fake_async_fcm_client_w_creds.send(message) 305 | 306 | 307 | def test_creds_from_service_account_info(fake_async_fcm_client, fake_service_account): 308 | fake_async_fcm_client.creds_from_service_account_info(fake_service_account) 309 | assert isinstance(fake_async_fcm_client._credentials, service_account.Credentials) 310 | 311 | 312 | def test_creds_from_service_account_file(fake_async_fcm_client, fake_service_account_file): 313 | fake_async_fcm_client.creds_from_service_account_file(fake_service_account_file) 314 | assert isinstance(fake_async_fcm_client._credentials, service_account.Credentials) 315 | 316 | 317 | async def test_send_realistic_payload(fake_async_fcm_client_w_creds, fake_device_token, httpx_mock: HTTPXMock): 318 | fake_async_fcm_client_w_creds._get_access_token = fake__get_access_token 319 | creds = fake_async_fcm_client_w_creds._credentials 320 | httpx_mock.add_response( 321 | status_code=200, 322 | json={"name": f"projects/{creds.project_id}/messages/0:1612788010922733%7606eb247606eb24"}, 323 | ) 324 | apns_config: APNSConfig = fake_async_fcm_client_w_creds.build_apns_config( 325 | priority="normal", 326 | apns_topic="Your bucket has been updated", 327 | collapse_key="BUCKET_UPDATED", 328 | badge=1, 329 | category="CATEGORY_BUCKET_UPDATED", 330 | custom_data={ 331 | "bucket_name": "3bc56ff12a", 332 | "bucket_link": "/link/to/bucket/3bc56ff12a", 333 | "aliases": ["happy_friends", "mobile_groups"], 334 | "updated_count": 1, 335 | }, 336 | mutable_content=True, 337 | content_available=True, 338 | ) 339 | message = Message(apns=apns_config, token=fake_device_token) 340 | await fake_async_fcm_client_w_creds.send(message) 341 | request_payload = json.loads(httpx_mock.get_requests()[0].read()) 342 | assert request_payload == { 343 | "message": { 344 | "apns": { 345 | "headers": apns_config.headers, 346 | "payload": { 347 | "aps": { 348 | "badge": 1, 349 | "category": "CATEGORY_BUCKET_UPDATED", 350 | "content-available": True, 351 | "mutable-content": True, 352 | }, 353 | "bucket_name": "3bc56ff12a", 354 | "bucket_link": "/link/to/bucket/3bc56ff12a", 355 | "aliases": ["happy_friends", "mobile_groups"], 356 | "updated_count": 1, 357 | }, 358 | }, 359 | "token": fake_device_token, 360 | }, 361 | "validate_only": False, 362 | } 363 | 364 | 365 | @pytest.mark.parametrize("fake_multi_device_tokens", (3,), indirect=True) 366 | async def test_send_all(fake_async_fcm_client_w_creds, fake_multi_device_tokens: list, httpx_mock: HTTPXMock): 367 | fake_async_fcm_client_w_creds._get_access_token = fake__get_access_token 368 | creds = fake_async_fcm_client_w_creds._credentials 369 | response_data = ( 370 | "\r\n--batch_llG_9dniIyeFXPERplIRPwpVYtn3RBa4\r\nContent-Type: application/http\r\nContent-ID: " 371 | "response-4440d691-7909-4346-af9a-b44f17638f6c\r\n\r\nHTTP/1.1 200 OK\r\nContent-Type: " 372 | "application/json; charset=UTF-8\r\nVary: Origin\r\nVary: X-Origin\r\nVary: Referer\r\n\r\n{\n " 373 | f'"name": "projects/{creds.project_id}/messages/0:1612788010922733%7606eb247606eb24"\n}}\n\r\n' 374 | "--batch_llG_9dniIyeFXPERplIRPwpVYtn3RBa4\r\nContent-Type: application/http\r\nContent-ID: " 375 | "response-fdbc3fd2-4031-4c00-88d2-22c9523bb941\r\n\r\nHTTP/1.1 200 OK\r\nContent-Type: " 376 | "application/json; charset=UTF-8\r\nVary: Origin\r\nVary: X-Origin\r\nVary: Referer\r\n\r\n{\n " 377 | f'"name": "projects/{creds.project_id}/messages/0:1612788010922733%7606eb247606eb25"\n}}\n\r\n' 378 | "--batch_llG_9dniIyeFXPERplIRPwpVYtn3RBa4\r\nContent-Type: application/http\r\nContent-ID: " 379 | "response-222748d1-1388-4c06-a48f-445f7aef19a9\r\n\r\nHTTP/1.1 200 OK\r\nContent-Type: " 380 | "application/json; charset=UTF-8\r\nVary: Origin\r\nVary: X-Origin\r\nVary: Referer\r\n\r\n{\n " 381 | f'"name": "projects/{creds.project_id}/messages/0:1612788010922733%7606eb247606eb26"\n}}\n\r\n' 382 | "--batch_llG_9dniIyeFXPERplIRPwpVYtn3RBa4--\r\n" 383 | ) 384 | 385 | httpx_mock.add_response( 386 | status_code=200, 387 | content=response_data.encode(), 388 | headers={"content-type": "multipart/mixed; boundary=batch_llG_9dniIyeFXPERplIRPwpVYtn3RBa4"}, 389 | ) 390 | apns_config = fake_async_fcm_client_w_creds.build_apns_config( 391 | priority="normal", 392 | apns_topic="test-push", 393 | collapse_key="push", 394 | badge=0, 395 | category="test-category", 396 | custom_data={"foo": "bar"}, 397 | ) 398 | messages = [ 399 | Message(apns=apns_config, token=fake_device_token) for fake_device_token in fake_multi_device_tokens 400 | ] 401 | response = await fake_async_fcm_client_w_creds.send_all(messages) 402 | assert isinstance(response, FCMBatchResponse) 403 | assert response.success_count == 3 404 | assert response.failure_count == 0 405 | assert response.responses[0].message_id == "projects/fake-mobile-app/messages/0:1612788010922733%7606eb247606eb24" 406 | assert response.responses[1].message_id == "projects/fake-mobile-app/messages/0:1612788010922733%7606eb247606eb25" 407 | assert response.responses[2].message_id == "projects/fake-mobile-app/messages/0:1612788010922733%7606eb247606eb26" 408 | 409 | 410 | @pytest.mark.parametrize("fake_multi_device_tokens", (3,), indirect=True) 411 | async def test_send_each_makes_proper_http_calls( 412 | fake_async_fcm_client_w_creds, fake_multi_device_tokens: list, httpx_mock: HTTPXMock 413 | ): 414 | fake_async_fcm_client_w_creds._get_access_token = fake__get_access_token 415 | creds = fake_async_fcm_client_w_creds._credentials 416 | fake_async_fcm_client_w_creds._get_access_token = fake__get_access_token 417 | creds = fake_async_fcm_client_w_creds._credentials 418 | response_message_ids = [ 419 | "0:1612788010922733%7606eb247606eb24", 420 | "0:1612788010922733%7606eb247606eb35", 421 | "0:1612788010922733%7606eb247606eb46", 422 | ] 423 | for message_id in response_message_ids: 424 | httpx_mock.add_response( 425 | status_code=200, 426 | json={"name": f"projects/{creds.project_id}/messages/{message_id}"}, 427 | ) 428 | apns_config: APNSConfig = fake_async_fcm_client_w_creds.build_apns_config( 429 | priority="normal", 430 | apns_topic="Your bucket has been updated", 431 | collapse_key="BUCKET_UPDATED", 432 | badge=1, 433 | category="CATEGORY_BUCKET_UPDATED", 434 | custom_data={"foo": "bar"}, 435 | mutable_content=True, 436 | content_available=True, 437 | ) 438 | messages = [ 439 | Message(apns=apns_config, token=fake_device_token) for fake_device_token in fake_multi_device_tokens 440 | ] 441 | await fake_async_fcm_client_w_creds.send_each(messages) 442 | request_payloads = [json.loads(request.read()) for request in httpx_mock.get_requests()] 443 | expected_request_payloads = [ 444 | { 445 | "message": { 446 | "apns": { 447 | "headers": apns_config.headers, 448 | "payload": { 449 | "aps": { 450 | "badge": 1, 451 | "category": "CATEGORY_BUCKET_UPDATED", 452 | "content-available": True, 453 | "mutable-content": True, 454 | }, 455 | "foo": "bar", 456 | }, 457 | }, 458 | "token": fake_device_token, 459 | }, 460 | "validate_only": False, 461 | } for fake_device_token in fake_multi_device_tokens 462 | ] 463 | for payload, expected_payload in zip(request_payloads, expected_request_payloads): 464 | assert payload == expected_payload 465 | 466 | 467 | @pytest.mark.parametrize("fake_multi_device_tokens", (3,), indirect=True) 468 | async def test_send_each_returns_correct_data( 469 | fake_async_fcm_client_w_creds, fake_multi_device_tokens: list, httpx_mock: HTTPXMock 470 | ): 471 | fake_async_fcm_client_w_creds._get_access_token = fake__get_access_token 472 | creds = fake_async_fcm_client_w_creds._credentials 473 | fake_async_fcm_client_w_creds._get_access_token = fake__get_access_token 474 | creds = fake_async_fcm_client_w_creds._credentials 475 | response_message_ids = [ 476 | "0:1612788010922733%7606eb247606eb24", 477 | "0:1612788010922733%7606eb247606eb35", 478 | "0:1612788010922733%7606eb247606eb46", 479 | ] 480 | for message_id in (response_message_ids[0], response_message_ids[1]): 481 | httpx_mock.add_response( 482 | status_code=200, 483 | json={"name": f"projects/{creds.project_id}/messages/{message_id}"}, 484 | ) 485 | httpx_mock.add_response(status_code=500) 486 | apns_config: APNSConfig = fake_async_fcm_client_w_creds.build_apns_config( 487 | priority="normal", 488 | apns_topic="Your bucket has been updated", 489 | collapse_key="BUCKET_UPDATED", 490 | badge=1, 491 | category="CATEGORY_BUCKET_UPDATED", 492 | custom_data={"foo": "bar"}, 493 | mutable_content=True, 494 | content_available=True, 495 | ) 496 | messages = [ 497 | Message(apns=apns_config, token=fake_device_token) for fake_device_token in fake_multi_device_tokens 498 | ] 499 | fcm_batch_response = await fake_async_fcm_client_w_creds.send_each(messages) 500 | 501 | assert fcm_batch_response.success_count == 2 502 | assert fcm_batch_response.failure_count == 1 503 | assert isinstance(fcm_batch_response, FCMBatchResponse) 504 | for fcm_response in fcm_batch_response.responses: 505 | assert isinstance(fcm_response, FCMResponse) 506 | 507 | # check successful responses 508 | for fcm_response, response_message_id in list(zip(fcm_batch_response.responses, response_message_ids))[1:2]: 509 | assert fcm_response.message_id == f"projects/{creds.project_id}/messages/{response_message_id}" 510 | assert fcm_response.exception is None 511 | 512 | # check failed response 513 | failed_fcm_response = fcm_batch_response.responses[2] 514 | assert failed_fcm_response.message_id is None 515 | assert isinstance(failed_fcm_response.exception, InternalError) 516 | 517 | 518 | @pytest.mark.parametrize("fake_multi_device_tokens", (3,), indirect=True) 519 | async def test_send_each_for_multicast( 520 | fake_async_fcm_client_w_creds, fake_multi_device_tokens: list, 521 | ): 522 | fake_async_fcm_client_w_creds._get_access_token = fake__get_access_token 523 | send_each_mock = mock.AsyncMock() 524 | fake_async_fcm_client_w_creds.send_each = send_each_mock 525 | apns_config = fake_async_fcm_client_w_creds.build_apns_config( 526 | priority="normal", 527 | apns_topic="test-push", 528 | collapse_key="push", 529 | badge=0, 530 | category="test-category", 531 | custom_data={"foo": "bar"}, 532 | ) 533 | await fake_async_fcm_client_w_creds.send_each_for_multicast( 534 | MulticastMessage(apns=apns_config, tokens=fake_multi_device_tokens), 535 | ) 536 | send_each_argument = send_each_mock.call_args[0][0] 537 | assert isinstance(send_each_argument, list) 538 | for message in send_each_argument: 539 | assert isinstance(message, Message) 540 | assert message.apns == apns_config 541 | assert message.token is not None 542 | 543 | 544 | @pytest.mark.parametrize("fake_multi_device_tokens", (3,), indirect=True) 545 | async def test_send_all_dry_run( 546 | fake_async_fcm_client_w_creds, fake_multi_device_tokens: list, httpx_mock: HTTPXMock 547 | ): 548 | fake_async_fcm_client_w_creds._get_access_token = fake__get_access_token 549 | creds = fake_async_fcm_client_w_creds._credentials 550 | response_data = ( 551 | "\r\n--batch_llG_9dniIyeFXPERplIRPwpVYtn3RBa4\r\nContent-Type: application/http\r\nContent-ID: " 552 | "response-4440d691-7909-4346-af9a-b44f17638f6c\r\n\r\nHTTP/1.1 200 OK\r\nContent-Type: " 553 | "application/json; charset=UTF-8\r\nVary: Origin\r\nVary: X-Origin\r\nVary: Referer\r\n\r\n{\n " 554 | f'"name": "projects/{creds.project_id}/messages/fake_message_id"\n}}\n\r\n' 555 | "--batch_llG_9dniIyeFXPERplIRPwpVYtn3RBa4\r\nContent-Type: application/http\r\nContent-ID: " 556 | "response-fdbc3fd2-4031-4c00-88d2-22c9523bb941\r\n\r\nHTTP/1.1 200 OK\r\nContent-Type: " 557 | "application/json; charset=UTF-8\r\nVary: Origin\r\nVary: X-Origin\r\nVary: Referer\r\n\r\n{\n " 558 | f'"name": "projects/{creds.project_id}/messages/fake_message_id"\n}}\n\r\n' 559 | "--batch_llG_9dniIyeFXPERplIRPwpVYtn3RBa4\r\nContent-Type: application/http\r\nContent-ID: " 560 | "response-222748d1-1388-4c06-a48f-445f7aef19a9\r\n\r\nHTTP/1.1 200 OK\r\nContent-Type: " 561 | "application/json; charset=UTF-8\r\nVary: Origin\r\nVary: X-Origin\r\nVary: Referer\r\n\r\n{\n " 562 | f'"name": "projects/{creds.project_id}/messages/fake_message_id"\n}}\n\r\n' 563 | "--batch_llG_9dniIyeFXPERplIRPwpVYtn3RBa4--\r\n" 564 | ) 565 | httpx_mock.add_response( 566 | status_code=200, 567 | content=response_data.encode(), 568 | headers={"content-type": "multipart/mixed; boundary=batch_llG_9dniIyeFXPERplIRPwpVYtn3RBa4"}, 569 | ) 570 | apns_config = fake_async_fcm_client_w_creds.build_apns_config( 571 | priority="normal", 572 | apns_topic="test-push", 573 | collapse_key="push", 574 | badge=0, 575 | category="test-category", 576 | custom_data={"foo": "bar"}, 577 | ) 578 | messages = [ 579 | Message(apns=apns_config, token=fake_device_token) for fake_device_token in fake_multi_device_tokens 580 | ] 581 | response = await fake_async_fcm_client_w_creds.send_all(messages, dry_run=True) 582 | 583 | assert isinstance(response, FCMBatchResponse) 584 | assert response.success_count == 3 585 | assert response.failure_count == 0 586 | assert response.responses[0].message_id == "projects/fake-mobile-app/messages/fake_message_id" 587 | assert response.responses[1].message_id == "projects/fake-mobile-app/messages/fake_message_id" 588 | assert response.responses[2].message_id == "projects/fake-mobile-app/messages/fake_message_id" 589 | 590 | 591 | @pytest.mark.parametrize("fake_multi_device_tokens", (501, 600, 1000), indirect=True) 592 | async def test_send_multicast_too_many_tokens( 593 | fake_async_fcm_client_w_creds, 594 | fake_multi_device_tokens: list, 595 | ): 596 | fake_async_fcm_client_w_creds._get_access_token = fake__get_access_token 597 | apns_config = fake_async_fcm_client_w_creds.build_apns_config( 598 | priority="normal", 599 | apns_topic="test-push", 600 | collapse_key="push", 601 | badge=0, 602 | category="test-category", 603 | custom_data={"foo": "bar"}, 604 | ) 605 | with pytest.raises(ValueError): 606 | await fake_async_fcm_client_w_creds.send_multicast( 607 | MulticastMessage(apns=apns_config, tokens=fake_multi_device_tokens), 608 | dry_run=True 609 | ) 610 | 611 | 612 | async def test_send_all_unknown_registration_token(fake_async_fcm_client_w_creds, httpx_mock: HTTPXMock): 613 | fake_async_fcm_client_w_creds._get_access_token = fake__get_access_token 614 | response_data = ( 615 | "\r\n--batch_HwFDZe-SUCq5qEgCavJPhhi8tA7xJBlB\r\nContent-Type: application/http\r\nContent-ID: " 616 | "response-363ad2c9-a3d1-45f5-b559-6d69a13a880e\r\n\r\nHTTP/1.1 400 Bad Request\r\nVary: Origin\r\nVary: " 617 | 'X-Origin\r\nVary: Referer\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n{\n "error": {\n ' 618 | '"code": 400,\n "message": "The registration token is not a valid FCM registration token",\n "status": ' 619 | '"INVALID_ARGUMENT",\n "details": [\n {\n "@type": ' 620 | '"type.googleapis.com/google.firebase.fcm.v1.FcmError",\n "errorCode": "INVALID_ARGUMENT"\n }\n ' 621 | " ]\n }\n}\n\r\n--batch_HwFDZe-SUCq5qEgCavJPhhi8tA7xJBlB--\r\n" 622 | ) 623 | httpx_mock.add_response( 624 | status_code=400, 625 | content=response_data.encode(), 626 | headers={"content-type": "multipart/mixed; boundary=batch_HwFDZe-SUCq5qEgCavJPhhi8tA7xJBlB"}, 627 | ) 628 | apns_config = fake_async_fcm_client_w_creds.build_apns_config( 629 | priority="normal", 630 | apns_topic="test-push", 631 | collapse_key="push", 632 | badge=0, 633 | category="test-category", 634 | custom_data={"foo": "bar"}, 635 | ) 636 | messages = [Message(apns=apns_config, token="qwerty:ytrewq")] 637 | response = await fake_async_fcm_client_w_creds.send_all(messages) 638 | 639 | assert isinstance(response, FCMBatchResponse) 640 | assert response.success_count == 0 641 | assert response.failure_count == 1 642 | assert response.responses[0].exception.code == FcmErrorCode.INVALID_ARGUMENT.value 643 | assert response.responses[0].exception.cause.response.status_code == 400 644 | 645 | 646 | async def test_send_response_error_invalid_argument(fake_async_fcm_client_w_creds, httpx_mock: HTTPXMock): 647 | fake_async_fcm_client_w_creds._get_access_token = fake__get_access_token 648 | response_data = ( 649 | '\r\n--batch_H3WKviwlw1OiFBuquMNPomHJtcBwS2Oi\r\n' 650 | 'Content-Type: application/http\r\n' 651 | 'Content-ID: response-37b4e119-2d98-4544-852d-082e429c18c2\r\n\r\n' 652 | 'HTTP/1.1 400 Bad Request\r\n' 653 | 'Vary: Origin\r\n' 654 | 'Vary: X-Origin\r\n' 655 | 'Vary: Referer\r\n' 656 | 'Content-Type: application/json; charset=UTF-8\r\n\r\n' 657 | '{\n "error": {\n "code": 400,\n "message": "Invalid value at \'message.data[1].value\' (TYPE_STRING), 10",\n' 658 | ' "status": "INVALID_ARGUMENT",\n "details": [\n {\n "@type": "type.googleapis.com/google.rpc.BadRequest",\n' 659 | ' "fieldViolations": [\n {\n "field": "message.data[1].value",\n' 660 | ' "description": "Invalid value at \'message.data[1].value\' (TYPE_STRING), 10"\n }\n ]\n }\n ]\n }\n}\n\r\n' 661 | '--batch_H3WKviwlw1OiFBuquMNPomHJtcBwS2Oi--\r\n' 662 | ) 663 | httpx_mock.add_response( 664 | status_code=400, 665 | content=response_data.encode(), 666 | headers={"content-type": "multipart/mixed; boundary=batch_HwFDZe-SUCq5qEgCavJPhhi8tA7xJBlB"}, 667 | ) 668 | apns_config = fake_async_fcm_client_w_creds.build_apns_config( 669 | priority="normal", 670 | apns_topic="test-push", 671 | collapse_key="push", 672 | badge=0, 673 | category="test-category", 674 | custom_data={"foo": "bar"}, 675 | ) 676 | messages = [Message(apns=apns_config, token="qwerty:ytrewq")] 677 | response = await fake_async_fcm_client_w_creds.send_all(messages) 678 | 679 | assert isinstance(response, FCMBatchResponse) 680 | assert response.success_count == 0 681 | assert response.failure_count == 1 682 | assert response.responses[0].exception.code == FcmErrorCode.INVALID_ARGUMENT.value 683 | assert response.responses[0].exception.cause.response.status_code == 400 684 | 685 | 686 | async def test_send_all_data_has_not_provided(fake_async_fcm_client_w_creds): 687 | messages = [Message(token="device_id:device_token")] 688 | with pytest.raises(ValueError): 689 | await fake_async_fcm_client_w_creds.send_all(messages) 690 | 691 | 692 | @pytest.mark.parametrize("fake_multi_device_tokens", (501, 600, 1000), indirect=True) 693 | async def test_send_all_too_many_messages( 694 | fake_async_fcm_client_w_creds, 695 | fake_multi_device_tokens: list, 696 | ): 697 | fake_async_fcm_client_w_creds._get_access_token = fake__get_access_token 698 | apns_config = fake_async_fcm_client_w_creds.build_apns_config( 699 | priority="normal", 700 | apns_topic="test-push", 701 | collapse_key="push", 702 | badge=0, 703 | category="test-category", 704 | custom_data={"foo": "bar"}, 705 | ) 706 | with pytest.raises(ValueError): 707 | await fake_async_fcm_client_w_creds.send_all( 708 | [Message(apns=apns_config, token=device_token) for device_token in fake_multi_device_tokens], 709 | dry_run=True 710 | ) 711 | 712 | 713 | @pytest.mark.parametrize( 714 | "apns_config, message, exp_push_notification", 715 | ( 716 | ( 717 | None, 718 | Message( 719 | token="token-1", 720 | data={"text": "hello"}, 721 | ), 722 | { 723 | "message": {"token": "token-1", "data": {"text": "hello"}}, 724 | "validate_only": True, 725 | }, 726 | ), 727 | ( 728 | APNSConfig(), 729 | Message( 730 | token="token-1", 731 | data={"text": "hello"}, 732 | ), 733 | { 734 | "message": {"token": "token-1", "data": {"text": "hello"}}, 735 | "validate_only": True, 736 | }, 737 | ), 738 | ( 739 | APNSConfig( 740 | payload=APNSPayload( 741 | aps=Aps( 742 | alert="push-text", 743 | badge=5, 744 | sound="default", 745 | content_available=True, 746 | category="NEW_MESSAGE", 747 | mutable_content=False, 748 | ) 749 | ) 750 | ), 751 | Message(token="token-1", apns=APNSConfig(payload=APNSPayload())), 752 | { 753 | "message": { 754 | "token": "token-1", 755 | "apns": { 756 | "payload": { 757 | "aps": { 758 | "alert": "push-text", 759 | "badge": 5, 760 | "sound": "default", 761 | "content-available": True, 762 | "category": "NEW_MESSAGE", 763 | "mutable-content": False, 764 | } 765 | } 766 | }, 767 | }, 768 | "validate_only": True, 769 | }, 770 | ), 771 | ), 772 | ) 773 | def test_assemble_push_notification(fake_async_fcm_client_w_creds, apns_config, message, exp_push_notification): 774 | push_notification = fake_async_fcm_client_w_creds.assemble_push_notification( 775 | apns_config=apns_config, dry_run=True, message=message 776 | ) 777 | assert push_notification == exp_push_notification 778 | 779 | 780 | def test_build_webpush_config(fake_async_fcm_client_w_creds): 781 | webpush_config = fake_async_fcm_client_w_creds.build_webpush_config( 782 | data={"attr_1": "value_1", "attr_2": "value_2"}, 783 | title="Test Webpush Title", 784 | body="Test Webpush Body", 785 | image="https://cdn.healhtjoy.com/public/test-image.png", 786 | language="en", 787 | tag="test", 788 | custom_data={"attr_3": "value_3", "attr_4": "value_4"}, 789 | link="https://link-to-something.domain.com" 790 | ) 791 | assert webpush_config == WebpushConfig( 792 | data={"attr_1": "value_1", "attr_2": "value_2"}, 793 | headers={}, 794 | notification=WebpushNotification( 795 | title="Test Webpush Title", 796 | body="Test Webpush Body", 797 | image="https://cdn.healhtjoy.com/public/test-image.png", 798 | language="en", 799 | tag="test", 800 | silent=False, 801 | renotify=False, 802 | actions=[], 803 | direction="auto", 804 | custom_data={"attr_3": "value_3", "attr_4": "value_4"}, 805 | ), 806 | fcm_options=WebpushFCMOptions(link="https://link-to-something.domain.com") 807 | ) 808 | 809 | 810 | @pytest.mark.parametrize("fake_multi_device_tokens", (3,), indirect=True) 811 | async def test_subscribe_to_topic(fake_async_fcm_client_w_creds, fake_multi_device_tokens, httpx_mock: HTTPXMock): 812 | fake_async_fcm_client_w_creds._get_access_token = fake__get_access_token 813 | httpx_mock.add_response( 814 | status_code=200, 815 | json={"results": [{}, {}, {}]}, 816 | ) 817 | response = await fake_async_fcm_client_w_creds.subscribe_devices_to_topic( 818 | topic_name="test_topic", device_tokens=fake_multi_device_tokens 819 | ) 820 | assert isinstance(response, TopicManagementResponse) 821 | assert response.success_count == 3 822 | assert response.errors == [] 823 | assert response.failure_count == 0 824 | 825 | 826 | @pytest.mark.parametrize("fake_multi_device_tokens", (3,), indirect=True) 827 | async def test_subscribe_to_topic_with_incorrect( 828 | fake_async_fcm_client_w_creds, fake_multi_device_tokens, httpx_mock: HTTPXMock 829 | ): 830 | fake_async_fcm_client_w_creds._get_access_token = fake__get_access_token 831 | 832 | device_tokens = [*fake_multi_device_tokens, "incorrect"] 833 | httpx_mock.add_response( 834 | status_code=200, 835 | json={"results": [{}, {}, {}, {"error": "INVALID_ARGUMENT"}]}, 836 | ) 837 | response = await fake_async_fcm_client_w_creds.subscribe_devices_to_topic( 838 | topic_name='test_topic', device_tokens=device_tokens 839 | ) 840 | assert isinstance(response, TopicManagementResponse) 841 | assert response.success_count == 3 842 | assert response.failure_count == 1 843 | assert len(response.errors) == 1 844 | 845 | assert isinstance(response.errors[0], TopicManagementErrorInfo) 846 | assert response.errors[0].index == 3 847 | assert response.errors[0].reason == "INVALID_ARGUMENT" 848 | 849 | 850 | @pytest.mark.parametrize("fake_multi_device_tokens", (3,), indirect=True) 851 | async def test_unsubscribe_to_topic(fake_async_fcm_client_w_creds, fake_multi_device_tokens, httpx_mock: HTTPXMock): 852 | fake_async_fcm_client_w_creds._get_access_token = fake__get_access_token 853 | httpx_mock.add_response( 854 | status_code=200, 855 | json={"results": [{}, {}, {}]}, 856 | ) 857 | response = await fake_async_fcm_client_w_creds.unsubscribe_devices_from_topic( 858 | topic_name="test_topic", device_tokens=fake_multi_device_tokens 859 | ) 860 | assert isinstance(response, TopicManagementResponse) 861 | assert response.success_count == 3 862 | assert response.errors == [] 863 | assert response.failure_count == 0 864 | 865 | 866 | @pytest.mark.parametrize("fake_multi_device_tokens", (3,), indirect=True) 867 | async def test_unsubscribe_to_topic_with_incorrect( 868 | fake_async_fcm_client_w_creds, fake_multi_device_tokens, httpx_mock: HTTPXMock 869 | ): 870 | fake_async_fcm_client_w_creds._get_access_token = fake__get_access_token 871 | 872 | device_tokens = [*fake_multi_device_tokens, "incorrect"] 873 | httpx_mock.add_response( 874 | status_code=200, 875 | json={"results": [{}, {}, {}, {"error": "INVALID_ARGUMENT"}]}, 876 | ) 877 | response = await fake_async_fcm_client_w_creds.unsubscribe_devices_from_topic( 878 | topic_name='test_topic', device_tokens=device_tokens 879 | ) 880 | assert isinstance(response, TopicManagementResponse) 881 | assert response.success_count == 3 882 | assert response.failure_count == 1 883 | assert len(response.errors) == 1 884 | 885 | assert isinstance(response.errors[0], TopicManagementErrorInfo) 886 | assert response.errors[0].index == 3 887 | assert response.errors[0].reason == "INVALID_ARGUMENT" 888 | 889 | 890 | @pytest.mark.parametrize("fake_multi_device_tokens", (3,), indirect=True) 891 | async def test_send_topic_management_unauthenticated( 892 | fake_async_fcm_client_w_creds, fake_multi_device_tokens, httpx_mock: HTTPXMock 893 | ): 894 | fake_async_fcm_client_w_creds._get_access_token = fake__get_access_token 895 | httpx_mock.add_response( 896 | status_code=401, 897 | json={ 898 | "error": { 899 | "code": 401, 900 | "message": "Request had invalid authentication credentials. " 901 | "Expected OAuth 2 access token, login cookie or other " 902 | "valid authentication credential. See " 903 | "https://developers.google.com/identity/sign-in/web/devconsole-project.", 904 | "status": "UNAUTHENTICATED", 905 | } 906 | }, 907 | ) 908 | response = await fake_async_fcm_client_w_creds.unsubscribe_devices_from_topic( 909 | topic_name="test_topic", device_tokens=fake_multi_device_tokens 910 | ) 911 | 912 | assert isinstance(response, TopicManagementResponse) 913 | assert not response.success_count 914 | assert response.exception is not None 915 | assert response.exception.code == FcmErrorCode.UNAUTHENTICATED.value 916 | assert response.exception.cause.response.status_code == 401 917 | --------------------------------------------------------------------------------