diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32ef50ae..d4f02efe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,12 +14,12 @@ jobs: python: ['3.9', '3.10', '3.11', '3.12', '3.13', 'pypy3.9'] steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up Python 3.13 for emulator - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.13' - name: Setup functions emulator environment @@ -29,7 +29,7 @@ jobs: pip install -r integration/emulators/functions/requirements.txt deactivate - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python }} - name: Install dependencies @@ -38,12 +38,12 @@ jobs: pip install -r requirements.txt - name: Test with pytest run: pytest - - name: Set up Node.js 20 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - name: Set up Node.js 24 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 20 + node-version: 24 - name: Set up Java 21 - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: 'temurin' java-version: '21' @@ -57,11 +57,11 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up Python 3.9 - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: 3.9 - name: Install dependencies diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 57240b23..adfc47fd 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -32,13 +32,13 @@ jobs: steps: - name: Checkout source for staging - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.client_payload.ref || github.ref }} persist-credentials: false - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: 3.9 @@ -67,14 +67,14 @@ jobs: # Attach the packaged artifacts to the workflow output. These can be manually # downloaded for later inspection if necessary. - name: Archive artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: dist path: dist - name: Send email on failure if: failure() - uses: firebase/firebase-admin-node/.github/actions/send-email@2e2b36a84ba28679bcb7aecdacabfec0bded2d48 # Admin Node SDK v13.6.0 + uses: firebase/firebase-admin-node/.github/actions/send-email@7343f166ec71c2efee5fe0554c2fb96203faf2a5 # Admin Node SDK v14.2.0 with: api-key: ${{ secrets.OSS_BOT_MAILGUN_KEY }} domain: ${{ secrets.OSS_BOT_MAILGUN_DOMAIN }} @@ -89,7 +89,7 @@ jobs: - name: Send email on cancelled if: cancelled() - uses: firebase/firebase-admin-node/.github/actions/send-email@2e2b36a84ba28679bcb7aecdacabfec0bded2d48 # Admin Node SDK v13.6.0 + uses: firebase/firebase-admin-node/.github/actions/send-email@7343f166ec71c2efee5fe0554c2fb96203faf2a5 # Admin Node SDK v14.2.0 with: api-key: ${{ secrets.OSS_BOT_MAILGUN_KEY }} domain: ${{ secrets.OSS_BOT_MAILGUN_DOMAIN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 510a1352..34f1d848 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,12 +49,12 @@ jobs: steps: - name: Checkout source for staging - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: 3.9 @@ -83,7 +83,7 @@ jobs: # Attach the packaged artifacts to the workflow output. These can be manually # downloaded for later inspection if necessary. - name: Archive artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: dist path: dist @@ -110,14 +110,14 @@ jobs: steps: - name: Checkout source for publish - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Ignored since the GITHUB_TOKEN needs to be persisted for this workflow. persist-credentials: true # zizmor: ignore[artipacked] # Download the artifacts created by the stage_release job. - name: Download release candidates - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: dist path: dist diff --git a/firebase_admin/__about__.py b/firebase_admin/__about__.py index 096dc67b..09b693d7 100644 --- a/firebase_admin/__about__.py +++ b/firebase_admin/__about__.py @@ -14,7 +14,7 @@ """About information (version, etc) for Firebase Admin SDK.""" -__version__ = '7.5.0' +__version__ = '7.7.0' __title__ = 'firebase_admin' __author__ = 'Firebase' __license__ = 'Apache License 2.0' diff --git a/firebase_admin/_auth_client.py b/firebase_admin/_auth_client.py index 74261fa3..254bd865 100644 --- a/firebase_admin/_auth_client.py +++ b/firebase_admin/_auth_client.py @@ -37,6 +37,9 @@ def __init__(self, app, tenant_id=None): 2. set the project ID explicitly via Firebase App options, or 3. set the project ID via the GOOGLE_CLOUD_PROJECT environment variable.""") + if tenant_id is not None: + _auth_utils.validate_tenant_id(tenant_id) + credential = None version_header = f'Python/Admin/{firebase_admin.__version__}' timeout = app.options.get('httpTimeout', _http_client.DEFAULT_TIMEOUT_SECONDS) diff --git a/firebase_admin/_auth_providers.py b/firebase_admin/_auth_providers.py index cc794952..232cf25e 100644 --- a/firebase_admin/_auth_providers.py +++ b/firebase_admin/_auth_providers.py @@ -182,7 +182,8 @@ def __init__(self, http_client, project_id, tenant_id=None, url_override=None): self.http_client = http_client url_prefix = url_override or self.PROVIDER_CONFIG_URL self.base_url = f'{url_prefix}/projects/{project_id}' - if tenant_id: + if tenant_id is not None: + _auth_utils.validate_tenant_id(tenant_id) self.base_url += f'/tenants/{tenant_id}' def get_oidc_provider_config(self, provider_id): @@ -218,7 +219,7 @@ def create_oidc_provider_config( if response_type: req['responseType'] = response_type - params = f'oauthIdpConfigId={provider_id}' + params = {'oauthIdpConfigId': provider_id} body = self._make_request('post', '/oauthIdpConfigs', json=req, params=params) return OIDCProviderConfig(body) @@ -301,7 +302,7 @@ def create_saml_provider_config( if enabled is not None: req['enabled'] = _auth_utils.validate_boolean(enabled, 'enabled') - params = f'inboundSamlConfigId={provider_id}' + params = {'inboundSamlConfigId': provider_id} body = self._make_request('post', '/inboundSamlConfigs', json=req, params=params) return SAMLProviderConfig(body) @@ -369,9 +370,9 @@ def _fetch_provider_configs(self, path, page_token=None, max_results=MAX_LIST_CO 'Max results must be a positive integer less than or equal to ' f'{MAX_LIST_CONFIGS_RESULTS}.') - params = f'pageSize={max_results}' + params = {'pageSize': max_results} if page_token: - params += f'&pageToken={page_token}' + params['pageToken'] = page_token return self._make_request('get', path, params=params) def _make_request(self, method, path, **kwargs): @@ -386,7 +387,7 @@ def _validate_oidc_provider_id(provider_id): if not isinstance(provider_id, str): raise ValueError( f'Invalid OIDC provider ID: {provider_id}. Provider ID must be a non-empty string.') - if not provider_id.startswith('oidc.'): + if not provider_id.startswith('oidc.') or not _auth_utils.is_safe_url_segment(provider_id): raise ValueError(f'Invalid OIDC provider ID: {provider_id}.') return provider_id @@ -395,7 +396,7 @@ def _validate_saml_provider_id(provider_id): if not isinstance(provider_id, str): raise ValueError( f'Invalid SAML provider ID: {provider_id}. Provider ID must be a non-empty string.') - if not provider_id.startswith('saml.'): + if not provider_id.startswith('saml.') or not _auth_utils.is_safe_url_segment(provider_id): raise ValueError(f'Invalid SAML provider ID: {provider_id}.') return provider_id diff --git a/firebase_admin/_auth_utils.py b/firebase_admin/_auth_utils.py index 8f3c419a..4dd39018 100644 --- a/firebase_admin/_auth_utils.py +++ b/firebase_admin/_auth_utils.py @@ -30,6 +30,7 @@ 'iss', 'jti', 'nbf', 'nonce', 'sub', 'firebase', ]) VALID_EMAIL_ACTION_TYPES = set(['VERIFY_EMAIL', 'EMAIL_SIGNIN', 'PASSWORD_RESET']) +_SAFE_URL_SEGMENT_PATTERN = re.compile(r'^[a-zA-Z0-9\-_.~]+\Z') class PageIterator: @@ -83,6 +84,22 @@ def is_emulated(): return get_emulator_host() != '' +def is_safe_url_segment(segment): + if not isinstance(segment, str) or not segment or segment.endswith('.'): + return False + return bool(_SAFE_URL_SEGMENT_PATTERN.match(segment)) + + +def validate_tenant_id(tenant_id): + if not isinstance(tenant_id, str) or not tenant_id: + raise ValueError( + f'Invalid tenant ID: {tenant_id}. Tenant ID must be a non-empty string.') + if not is_safe_url_segment(tenant_id): + raise ValueError( + f'Invalid tenant ID: {tenant_id}. Tenant ID must be a valid URL path segment.') + return tenant_id + + def validate_uid(uid, required=False): if uid is None and not required: return None diff --git a/firebase_admin/_user_mgt.py b/firebase_admin/_user_mgt.py index e7825499..4d852500 100644 --- a/firebase_admin/_user_mgt.py +++ b/firebase_admin/_user_mgt.py @@ -602,7 +602,8 @@ def __init__(self, http_client, project_id, tenant_id=None, url_override=None): self.http_client = http_client url_prefix = url_override or self.ID_TOOLKIT_URL self.base_url = f'{url_prefix}/projects/{project_id}' - if tenant_id: + if tenant_id is not None: + _auth_utils.validate_tenant_id(tenant_id) self.base_url += f'/tenants/{tenant_id}' def get_user(self, **kwargs): diff --git a/firebase_admin/app_check.py b/firebase_admin/app_check.py index 40d857f4..7dbd8fe6 100644 --- a/firebase_admin/app_check.py +++ b/firebase_admin/app_check.py @@ -15,38 +15,47 @@ """Firebase App Check module.""" from typing import Any, Dict +import requests import jwt from jwt import PyJWKClient, ExpiredSignatureError, InvalidTokenError, DecodeError from jwt import InvalidAudienceError, InvalidIssuerError, InvalidSignatureError -from firebase_admin import _utils +from firebase_admin import _http_client, _utils, exceptions _APP_CHECK_ATTRIBUTE = '_app_check' def _get_app_check_service(app) -> Any: return _utils.get_app_service(app, _APP_CHECK_ATTRIBUTE, _AppCheckService) -def verify_token(token: str, app=None) -> Dict[str, Any]: - """Verifies a Firebase App Check token. +def verify_token(token: str, app=None, consume: bool = False) -> Dict[str, Any]: + """Verifies a Firebase App Check token, optionally consuming limited-use tokens. Args: token: A token from App Check. app: An App instance (optional). + consume: Set to ``True`` only if the token is a limited-use (one-time) token + that should be consumed upon verification (optional, defaults to ``False``). Returns: - Dict[str, Any]: The token's decoded claims. + Dict[str, Any]: The token's decoded claims. If ``consume`` is ``True``, the dictionary + also includes an ``already_consumed`` boolean key indicating whether the token was + previously consumed. Raises: - ValueError: If the app's ``project_id`` is invalid or unspecified, - or if the token's headers or payload are invalid. + ValueError: If ``consume`` is not a boolean, or if the app's ``project_id`` + is invalid or unspecified, or if the token's headers or payload are invalid. + FirebaseError: If an error occurs while communicating with the App Check service. PyJWKClientError: If PyJWKClient fails to fetch a valid signing key. """ - return _get_app_check_service(app).verify_token(token) + return _get_app_check_service(app).verify_token(token, consume=consume) class _AppCheckService: """Service class that implements Firebase App Check functionality.""" _APP_CHECK_ISSUER = 'https://firebaseappcheck.googleapis.com/' _JWKS_URL = 'https://firebaseappcheck.googleapis.com/v1/jwks' + _VERIFY_URL_FORMAT = ( + 'https://firebaseappcheck.googleapis.com/v1beta/projects/{project_id}:verifyAppCheckToken' + ) _project_id = None _scoped_project_id = None _jwks_client = None @@ -68,11 +77,15 @@ def __init__(self, app): # Default lifespan is 300 seconds (5 minutes) so we change it to 21600 seconds (6 hours). self._jwks_client = PyJWKClient( self._JWKS_URL, lifespan=21600, headers=self._APP_CHECK_HEADERS) + timeout = app.options.get('httpTimeout', _http_client.DEFAULT_TIMEOUT_SECONDS) + self._http_client = _http_client.JsonHttpClient( + credential=app.credential.get_credential(), timeout=timeout) - def verify_token(self, token: str) -> Dict[str, Any]: - """Verifies a Firebase App Check token.""" + def verify_token(self, token: str, consume: bool = False) -> Dict[str, Any]: + """Verifies a Firebase App Check token, optionally consuming limited-use tokens.""" _Validators.check_string("app check token", token) + _Validators.check_boolean("consume", consume) # Obtain the Firebase App Check Public Keys # Note: It is not recommended to hard code these keys as they rotate, @@ -87,8 +100,32 @@ def verify_token(self, token: str) -> Dict[str, Any]: ) from exception verified_claims['app_id'] = verified_claims.get('sub') + + if consume: + verified_claims['already_consumed'] = self._verify_replay_protection(token) + return verified_claims + def _verify_replay_protection(self, token: str) -> bool: + """Verifies replay protection with the backend and returns the alreadyConsumed status.""" + url = self._VERIFY_URL_FORMAT.format(project_id=self._project_id) + try: + body = self._http_client.body('post', url, json={'app_check_token': token}) + except requests.exceptions.RequestException as error: + raise _utils.handle_platform_error_from_requests(error) + except ValueError as error: + raise exceptions.UnknownError( + f'Unexpected response from App Check service: {error}' + ) from error + + if not isinstance(body, dict): + raise exceptions.UnknownError( + 'Unexpected response from App Check service. ' + f'Expected a JSON object, but got {type(body).__name__}.' + ) + + return bool(body.get('alreadyConsumed', False)) + def _has_valid_token_headers(self, headers: Any) -> None: """Checks whether the token has valid headers for App Check.""" # Ensure the token's header has type JWT @@ -159,3 +196,10 @@ def check_string(cls, label: str, value: Any): raise ValueError(f'{label} "{value}" must be a non-empty string.') if not isinstance(value, str): raise ValueError(f'{label} "{value}" must be a string.') + + @classmethod + def check_boolean(cls, label: str, value: Any): + """Checks if the given value is a boolean.""" + if not isinstance(value, bool): + raise ValueError(f'{label} must be a boolean.') + return value diff --git a/firebase_admin/messaging.py b/firebase_admin/messaging.py index 93ecfda1..a108444a 100644 --- a/firebase_admin/messaging.py +++ b/firebase_admin/messaging.py @@ -15,14 +15,17 @@ """Firebase Cloud Messaging module.""" from __future__ import annotations -from typing import Any, Callable, Dict, List, Optional, cast +import asyncio import concurrent.futures import json -import asyncio import logging +import re +from typing import Any, Callable, Dict, List, Optional, Union, cast +import urllib.parse import warnings -import requests + import httpx +import requests import firebase_admin from firebase_admin import ( @@ -73,7 +76,11 @@ 'send_each_for_multicast', 'send_each_for_multicast_async', 'subscribe_to_topic', + 'subscribe_to_topic_async', + 'subscribe_to_topic_legacy', 'unsubscribe_from_topic', + 'unsubscribe_from_topic_async', + 'unsubscribe_from_topic_legacy', ] @@ -252,9 +259,53 @@ def send_each_for_multicast(multicast_message, dry_run=False, app=None): messages = _get_messages_from_multicast(multicast_message) return _get_messaging_service(app).send_each(messages, dry_run) -def subscribe_to_topic(tokens, topic, app=None): +def subscribe_to_topic( + tokens: Union[str, List[str]], topic: str, app: Optional[App] = None +) -> TopicManagementResponse: """Subscribes a list of registration tokens to an FCM topic. + Args: + tokens: A non-empty list of device registration tokens. List may not have more than 1000 + elements. + topic: Name of the topic to subscribe to. May contain the ``/topics/`` prefix. + app: An App instance (optional). + + Returns: + TopicManagementResponse: A ``TopicManagementResponse`` instance. + + Raises: + FirebaseError: If an error occurs while communicating with the FCM service. + ValueError: If the input arguments are invalid. + """ + return _get_messaging_service(app).subscribe_to_topic(tokens, topic) + +async def subscribe_to_topic_async( + tokens: Union[str, List[str]], topic: str, app: Optional[App] = None +) -> TopicManagementResponse: + """Subscribes a list of registration tokens to an FCM topic asynchronously. + + Args: + tokens: A non-empty list of device registration tokens. List may not have more than 1000 + elements. + topic: Name of the topic to subscribe to. May contain the ``/topics/`` prefix. + app: An App instance (optional). + + Returns: + TopicManagementResponse: A ``TopicManagementResponse`` instance. + + Raises: + FirebaseError: If an error occurs while communicating with the FCM service. + ValueError: If the input arguments are invalid. + """ + return await _get_messaging_service(app).subscribe_to_topic_async(tokens, topic) + +def subscribe_to_topic_legacy( + tokens: Union[str, List[str]], topic: str, app: Optional[App] = None +) -> TopicManagementResponse: + """Subscribes a list of registration tokens to an FCM topic using the legacy Instance ID API. + + subscribe_to_topic_legacy is deprecated. Use subscribe_to_topic instead. + Args: tokens: A non-empty list of device registration tokens. List may not have more than 1000 elements. @@ -268,12 +319,61 @@ def subscribe_to_topic(tokens, topic, app=None): FirebaseError: If an error occurs while communicating with instance ID service. ValueError: If the input arguments are invalid. """ + warnings.warn( + 'subscribe_to_topic_legacy is deprecated. Use subscribe_to_topic instead.', + DeprecationWarning, + stacklevel=2) return _get_messaging_service(app).make_topic_management_request( tokens, topic, 'iid/v1:batchAdd') -def unsubscribe_from_topic(tokens, topic, app=None): +def unsubscribe_from_topic( + tokens: Union[str, List[str]], topic: str, app: Optional[App] = None +) -> TopicManagementResponse: """Unsubscribes a list of registration tokens from an FCM topic. + Args: + tokens: A non-empty list of device registration tokens. List may not have more than 1000 + elements. + topic: Name of the topic to unsubscribe from. May contain the ``/topics/`` prefix. + app: An App instance (optional). + + Returns: + TopicManagementResponse: A ``TopicManagementResponse`` instance. + + Raises: + FirebaseError: If an error occurs while communicating with the FCM service. + ValueError: If the input arguments are invalid. + """ + return _get_messaging_service(app).unsubscribe_from_topic(tokens, topic) + +async def unsubscribe_from_topic_async( + tokens: Union[str, List[str]], topic: str, app: Optional[App] = None +) -> TopicManagementResponse: + """Unsubscribes a list of registration tokens from an FCM topic asynchronously. + + Args: + tokens: A non-empty list of device registration tokens. List may not have more than 1000 + elements. + topic: Name of the topic to unsubscribe from. May contain the ``/topics/`` prefix. + app: An App instance (optional). + + Returns: + TopicManagementResponse: A ``TopicManagementResponse`` instance. + + Raises: + FirebaseError: If an error occurs while communicating with the FCM service. + ValueError: If the input arguments are invalid. + """ + return await _get_messaging_service(app).unsubscribe_from_topic_async(tokens, topic) + +def unsubscribe_from_topic_legacy( + tokens: Union[str, List[str]], topic: str, app: Optional[App] = None +) -> TopicManagementResponse: + """Unsubscribes a list of registration tokens from an FCM topic using the legacy + Instance ID API. + + unsubscribe_from_topic_legacy is deprecated. Use unsubscribe_from_topic instead. + Args: tokens: A non-empty list of device registration tokens. List may not have more than 1000 elements. @@ -287,6 +387,10 @@ def unsubscribe_from_topic(tokens, topic, app=None): FirebaseError: If an error occurs while communicating with instance ID service. ValueError: If the input arguments are invalid. """ + warnings.warn( + 'unsubscribe_from_topic_legacy is deprecated. Use unsubscribe_from_topic instead.', + DeprecationWarning, + stacklevel=2) return _get_messaging_service(app).make_topic_management_request( tokens, topic, 'iid/v1:batchRemove') @@ -410,7 +514,9 @@ def __init__(self, app: App) -> None: 'Project ID is required to access Cloud Messaging service. Either set the ' 'projectId option, or use service account credentials. Alternatively, set the ' 'GOOGLE_CLOUD_PROJECT environment variable.') + self._project_id = project_id self._fcm_url = _MessagingService.FCM_URL.format(project_id) + self._fcm_topic_url = f'https://fcm.googleapis.com/v1/projects/{project_id}/registrations' self._fcm_headers = { 'X-GOOG-API-FORMAT-VERSION': '2', 'X-FIREBASE-CLIENT': f'fire-admin-python/{firebase_admin.__version__}', @@ -418,6 +524,12 @@ def __init__(self, app: App) -> None: timeout = app.options.get('httpTimeout', _http_client.DEFAULT_TIMEOUT_SECONDS) self._credential = app.credential.get_credential() self._client = _http_client.JsonHttpClient(credential=self._credential, timeout=timeout) + fcm_adapter = requests.adapters.HTTPAdapter( + pool_connections=100, + pool_maxsize=100, + max_retries=_http_client.DEFAULT_RETRY_CONFIG + ) + self._client.session.mount('https://fcm.googleapis.com', fcm_adapter) self._async_client = _http_client.HttpxAsyncClient( credential=self._credential, timeout=timeout) @@ -434,7 +546,7 @@ def send(self, message: Message, dry_run: bool = False) -> str: resp = self._client.body( 'post', url=self._fcm_url, - headers=self._fcm_headers, + headers=dict(self._fcm_headers), json=data ) except requests.exceptions.RequestException as error: @@ -453,7 +565,7 @@ def send_data(data): resp = self._client.body( 'post', url=self._fcm_url, - headers=self._fcm_headers, + headers=dict(self._fcm_headers), json=data) except requests.exceptions.RequestException as exception: return SendResponse(resp=None, exception=self._handle_fcm_error(exception)) @@ -481,7 +593,7 @@ async def send_data(data): resp = await self._async_client.request( 'post', url=self._fcm_url, - headers=self._fcm_headers, + headers=dict(self._fcm_headers), json=data) except httpx.HTTPError as exception: return SendResponse(resp=None, exception=self._handle_fcm_httpx_error(exception)) @@ -499,6 +611,225 @@ async def send_data(data): message=f'Unknown error while making remote service calls: {error}', cause=error) + def _validate_topic_management_args(self, tokens, topic): + """Validates and formats topic management arguments.""" + if isinstance(tokens, str): + tokens = [tokens] + if not isinstance(tokens, list) or not tokens: + raise ValueError('Tokens must be a string or a non-empty list of strings.') + invalid_str = [t for t in tokens if not isinstance(t, str) or not t] + if invalid_str: + raise ValueError('Tokens must be non-empty strings.') + if len(tokens) > 1000: + raise ValueError('tokens must not contain more than 1000 elements.') + + if not isinstance(topic, str) or not topic: + raise ValueError('Topic must be a non-empty string.') + topic_name = topic + if topic_name.startswith('/topics/'): + topic_name = topic_name[len('/topics/'):] + if not topic_name or not re.match(r'^[a-zA-Z0-9-_\.~%]+\Z', topic_name): + raise ValueError('Malformed topic name.') + + return tokens, topic_name + + def subscribe_to_topic( + self, tokens: Union[str, List[str]], topic: str + ) -> TopicManagementResponse: + """Subscribes a list of registration tokens to an FCM topic via the FCM v1 API.""" + return self._make_topic_management_request_v1(tokens, topic, is_subscribe=True) + + def unsubscribe_from_topic( + self, tokens: Union[str, List[str]], topic: str + ) -> TopicManagementResponse: + """Unsubscribes a list of registration tokens from an FCM topic via the FCM v1 API.""" + return self._make_topic_management_request_v1(tokens, topic, is_subscribe=False) + + async def subscribe_to_topic_async( + self, tokens: Union[str, List[str]], topic: str + ) -> TopicManagementResponse: + """Subscribes a list of registration tokens to an FCM topic asynchronously + via the FCM v1 API.""" + return await self._make_topic_management_request_v1_async( + tokens, topic, is_subscribe=True) + + async def unsubscribe_from_topic_async( + self, tokens: Union[str, List[str]], topic: str + ) -> TopicManagementResponse: + """Unsubscribes a list of registration tokens from an FCM topic asynchronously + via the FCM v1 API.""" + return await self._make_topic_management_request_v1_async( + tokens, topic, is_subscribe=False) + + def _make_topic_management_request_v1( + self, tokens, topic, is_subscribe: bool + ) -> TopicManagementResponse: + """Helper method that sends topic subscription requests via FCM v1 API.""" + tokens_list, topic_name = self._validate_topic_management_args(tokens, topic) + encoded_topic = urllib.parse.quote(topic_name, safe='') + + def send_request(token: str): + encoded_token = urllib.parse.quote(token, safe='') + base_url = f'{self._fcm_topic_url}/{encoded_token}/topicSubscriptions' + if is_subscribe: + url = f'{base_url}?topic_name={encoded_topic}' + method = 'post' + json_data = {} + else: + url = f'{base_url}/{encoded_topic}?allow_missing=true' + method = 'delete' + json_data = None + + try: + self._client.request( + method, + url=url, + headers=dict(self._fcm_headers), + json=json_data, + ) + return {'success': True} + except requests.exceptions.RequestException as error: + return self._build_topic_subscription_result_from_requests_error( + error, is_subscribe) + + try: + with concurrent.futures.ThreadPoolExecutor( + max_workers=min(len(tokens_list), 100) + ) as executor: + results = list(executor.map(send_request, tokens_list)) + return self._parse_topic_management_results(results) + except Exception as error: + raise exceptions.UnknownError( + message=f'Unknown error while making remote service calls: {error}', + cause=error) + + async def _make_topic_management_request_v1_async( + self, tokens, topic, is_subscribe: bool + ) -> TopicManagementResponse: + """Helper method that sends topic subscription requests asynchronously via FCM v1 API.""" + tokens_list, topic_name = self._validate_topic_management_args(tokens, topic) + encoded_topic = urllib.parse.quote(topic_name, safe='') + semaphore = asyncio.Semaphore(100) + + async def send_request_async(token: str): + encoded_token = urllib.parse.quote(token, safe='') + base_url = f'{self._fcm_topic_url}/{encoded_token}/topicSubscriptions' + if is_subscribe: + url = f'{base_url}?topic_name={encoded_topic}' + method = 'post' + json_data = {} + else: + url = f'{base_url}/{encoded_topic}?allow_missing=true' + method = 'delete' + json_data = None + + async with semaphore: + try: + await self._async_client.request( + method, + url=url, + headers=dict(self._fcm_headers), + json=json_data, + ) + return {'success': True} + except httpx.HTTPError as error: + return self._build_topic_subscription_result_from_httpx_error( + error, is_subscribe) + except requests.exceptions.RequestException as error: + return self._build_topic_subscription_result_from_requests_error( + error, is_subscribe) + + try: + results = await asyncio.gather(*[send_request_async(token) for token in tokens_list]) + return self._parse_topic_management_results(results) + except Exception as error: + raise exceptions.UnknownError( + message=f'Unknown error while making remote service calls: {error}', + cause=error) + + @classmethod + def _get_topic_error_code(cls, error_dict: dict, status_code: int) -> str: + """Extracts the error code for a topic subscription error response.""" + error_data = error_dict.get('error') + if isinstance(error_data, str) and error_data: + return error_data + if isinstance(error_data, dict): + details = error_data.get('details') + if isinstance(details, list): + fcm_error_type = 'type.googleapis.com/google.firebase.fcm.v1.FcmError' + for element in details: + if isinstance(element, dict) and element.get('@type') == fcm_error_type: + code = element.get('errorCode') + if code: + return code + status = error_data.get('status') + if status: + return status + + status_map = { + 400: 'INVALID_ARGUMENT', + 401: 'UNAUTHENTICATED', + 403: 'PERMISSION_DENIED', + 404: 'NOT_FOUND', + 408: 'DEADLINE_EXCEEDED', + 429: 'RESOURCE_EXHAUSTED', + 500: 'INTERNAL', + 503: 'UNAVAILABLE', + 504: 'DEADLINE_EXCEEDED', + } + if status_code in status_map: + return status_map[status_code] + + if isinstance(error_data, dict): + message = error_data.get('message') + if message: + return message + + return 'UNKNOWN_ERROR' + + def _build_topic_subscription_result(self, response, is_subscribe): + """Constructs a result dict from a response object.""" + if response is not None: + if is_subscribe and response.status_code == 409: + return {'success': True} + error_dict = {} + try: + parsed = response.json() + if isinstance(parsed, dict): + error_dict = parsed + except ValueError: + pass + + error_data = error_dict.get('error') + if is_subscribe and isinstance(error_data, dict) and ( + error_data.get('status') == 'ALREADY_EXISTS' + ): + return {'success': True} + + error_code = self._get_topic_error_code(error_dict, response.status_code) + return {'success': False, 'error': error_code} + + return {'success': False, 'error': 'UNKNOWN_ERROR'} + + def _build_topic_subscription_result_from_requests_error(self, error, is_subscribe): + """Constructs a result dict from a requests error.""" + return self._build_topic_subscription_result(error.response, is_subscribe) + + def _build_topic_subscription_result_from_httpx_error(self, error, is_subscribe): + """Constructs a result dict from an httpx error.""" + response = error.response if isinstance(error, httpx.HTTPStatusError) else None + return self._build_topic_subscription_result(response, is_subscribe) + + def _parse_topic_management_results(self, results) -> TopicManagementResponse: + """Parses individual request results into a TopicManagementResponse.""" + formatted_results = [] + for result in results: + if result.get('success'): + formatted_results.append({}) + else: + formatted_results.append({'error': result.get('error', 'UNKNOWN_ERROR')}) + return TopicManagementResponse({'results': formatted_results}) + def make_topic_management_request(self, tokens, topic, operation): """Invokes the IID service for topic management functionality.""" if isinstance(tokens, str): diff --git a/firebase_admin/ml.py b/firebase_admin/ml.py index 3a77dd05..2257383b 100644 --- a/firebase_admin/ml.py +++ b/firebase_admin/ml.py @@ -14,16 +14,25 @@ """Firebase ML module. +.. deprecated:: + Firebase ML is deprecated and will be shut down on June 15, 2027. + To host custom models, you must migrate to another solution. You can use Cloud Storage for + Firebase as an alternative for hosting custom models. For more info, see + https://firebase.google.com/docs/ml/migrate-to-cloud-storage + This module contains functions for creating, updating, getting, listing, deleting, publishing and unpublishing Firebase ML models. """ +# pylint: disable=too-many-lines import datetime +import os import re +import sys import time -import os from urllib import parse +import warnings import requests @@ -46,6 +55,30 @@ except ImportError: _TF_ENABLED = False +_DEPRECATION_WARNING = ( + 'Firebase ML is deprecated and will be shut down on June 15, 2027. ' + 'To host custom models, you must migrate to another solution. You can use Cloud Storage for ' + 'Firebase as an alternative for hosting custom models. For more info, see ' + 'https://firebase.google.com/docs/ml/migrate-to-cloud-storage' +) + + +def _is_internal_call(): + """Checks if the current call originated from within the ML module.""" + try: + # pylint: disable=protected-access + frame = sys._getframe(2) + while frame: + if frame.f_code.co_name in ( + 'from_dict', '_update_from_dict', 'handle_operation', 'list_models' + ): + return True + frame = frame.f_back + except (AttributeError, ValueError): + pass + return False + + _ML_ATTRIBUTE = '_ml' _MAX_PAGE_SIZE = 100 _MODEL_ID_PATTERN = re.compile(r'^[A-Za-z0-9_-]{1,60}$') @@ -77,6 +110,12 @@ def _get_ml_service(app): def create_model(model, app=None): """Creates a model in the current Firebase project. + .. deprecated:: + Firebase ML is deprecated and will be shut down on June 15, 2027. + To host custom models, you must migrate to another solution. You can use Cloud Storage for + Firebase as an alternative for hosting custom models. For more info, see + https://firebase.google.com/docs/ml/migrate-to-cloud-storage + Args: model: An ml.Model to create. app: A Firebase app instance (or None to use the default app). @@ -84,6 +123,8 @@ def create_model(model, app=None): Returns: Model: The model that was created in Firebase ML. """ + if not _is_internal_call(): + warnings.warn(_DEPRECATION_WARNING, DeprecationWarning, stacklevel=2) ml_service = _get_ml_service(app) return Model.from_dict(ml_service.create_model(model), app=app) @@ -91,6 +132,12 @@ def create_model(model, app=None): def update_model(model, app=None): """Updates a model's metadata or model file. + .. deprecated:: + Firebase ML is deprecated and will be shut down on June 15, 2027. + To host custom models, you must migrate to another solution. You can use Cloud Storage for + Firebase as an alternative for hosting custom models. For more info, see + https://firebase.google.com/docs/ml/migrate-to-cloud-storage + Args: model: The ml.Model to update. app: A Firebase app instance (or None to use the default app). @@ -98,6 +145,8 @@ def update_model(model, app=None): Returns: Model: The updated model. """ + if not _is_internal_call(): + warnings.warn(_DEPRECATION_WARNING, DeprecationWarning, stacklevel=2) ml_service = _get_ml_service(app) return Model.from_dict(ml_service.update_model(model), app=app) @@ -105,6 +154,12 @@ def update_model(model, app=None): def publish_model(model_id, app=None): """Publishes a Firebase ML model. + .. deprecated:: + Firebase ML is deprecated and will be shut down on June 15, 2027. + To host custom models, you must migrate to another solution. You can use Cloud Storage for + Firebase as an alternative for hosting custom models. For more info, see + https://firebase.google.com/docs/ml/migrate-to-cloud-storage + A published model can be downloaded to client apps. Args: @@ -114,6 +169,8 @@ def publish_model(model_id, app=None): Returns: Model: The published model. """ + if not _is_internal_call(): + warnings.warn(_DEPRECATION_WARNING, DeprecationWarning, stacklevel=2) ml_service = _get_ml_service(app) return Model.from_dict(ml_service.set_published(model_id, publish=True), app=app) @@ -121,6 +178,12 @@ def publish_model(model_id, app=None): def unpublish_model(model_id, app=None): """Unpublishes a Firebase ML model. + .. deprecated:: + Firebase ML is deprecated and will be shut down on June 15, 2027. + To host custom models, you must migrate to another solution. You can use Cloud Storage for + Firebase as an alternative for hosting custom models. For more info, see + https://firebase.google.com/docs/ml/migrate-to-cloud-storage + Args: model_id: The id of the model to unpublish. app: A Firebase app instance (or None to use the default app). @@ -128,6 +191,8 @@ def unpublish_model(model_id, app=None): Returns: Model: The unpublished model. """ + if not _is_internal_call(): + warnings.warn(_DEPRECATION_WARNING, DeprecationWarning, stacklevel=2) ml_service = _get_ml_service(app) return Model.from_dict(ml_service.set_published(model_id, publish=False), app=app) @@ -135,6 +200,12 @@ def unpublish_model(model_id, app=None): def get_model(model_id, app=None): """Gets the model specified by the given ID. + .. deprecated:: + Firebase ML is deprecated and will be shut down on June 15, 2027. + To host custom models, you must migrate to another solution. You can use Cloud Storage for + Firebase as an alternative for hosting custom models. For more info, see + https://firebase.google.com/docs/ml/migrate-to-cloud-storage + Args: model_id: The id of the model to get. app: A Firebase app instance (or None to use the default app). @@ -142,6 +213,8 @@ def get_model(model_id, app=None): Returns: Model: The requested model. """ + if not _is_internal_call(): + warnings.warn(_DEPRECATION_WARNING, DeprecationWarning, stacklevel=2) ml_service = _get_ml_service(app) return Model.from_dict(ml_service.get_model(model_id), app=app) @@ -149,6 +222,12 @@ def get_model(model_id, app=None): def list_models(list_filter=None, page_size=None, page_token=None, app=None): """Lists the current project's models. + .. deprecated:: + Firebase ML is deprecated and will be shut down on June 15, 2027. + To host custom models, you must migrate to another solution. You can use Cloud Storage for + Firebase as an alternative for hosting custom models. For more info, see + https://firebase.google.com/docs/ml/migrate-to-cloud-storage + Args: list_filter: a list filter string such as ``tags:'tag_1'``. None will return all models. page_size: A number between 1 and 100 inclusive that specifies the maximum @@ -160,6 +239,8 @@ def list_models(list_filter=None, page_size=None, page_token=None, app=None): Returns: ListModelsPage: A (filtered) list of models. """ + if not _is_internal_call(): + warnings.warn(_DEPRECATION_WARNING, DeprecationWarning, stacklevel=2) ml_service = _get_ml_service(app) return ListModelsPage( ml_service.list_models, list_filter, page_size, page_token, app=app) @@ -168,10 +249,18 @@ def list_models(list_filter=None, page_size=None, page_token=None, app=None): def delete_model(model_id, app=None): """Deletes a model from the current project. + .. deprecated:: + Firebase ML is deprecated and will be shut down on June 15, 2027. + To host custom models, you must migrate to another solution. You can use Cloud Storage for + Firebase as an alternative for hosting custom models. For more info, see + https://firebase.google.com/docs/ml/migrate-to-cloud-storage + Args: model_id: The id of the model you wish to delete. app: A Firebase app instance (or None to use the default app). """ + if not _is_internal_call(): + warnings.warn(_DEPRECATION_WARNING, DeprecationWarning, stacklevel=2) ml_service = _get_ml_service(app) ml_service.delete_model(model_id) @@ -179,12 +268,20 @@ def delete_model(model_id, app=None): class Model: """A Firebase ML Model object. + .. deprecated:: + Firebase ML is deprecated and will be shut down on June 15, 2027. + To host custom models, you must migrate to another solution. You can use Cloud Storage for + Firebase as an alternative for hosting custom models. For more info, see + https://firebase.google.com/docs/ml/migrate-to-cloud-storage + Args: display_name: The display name of your model - used to identify your model in code. tags: Optional list of strings associated with your model. Can be used in list queries. model_format: A subclass of ModelFormat. (e.g. TFLiteFormat) Specifies the model details. """ def __init__(self, display_name=None, tags=None, model_format=None): + if not _is_internal_call(): + warnings.warn(_DEPRECATION_WARNING, DeprecationWarning, stacklevel=2) self._app = None # Only needed for wait_for_unlo self._data = {} self._model_format = None @@ -342,7 +439,14 @@ def as_dict(self, for_upload=False): class ModelFormat: - """Abstract base class representing a Model Format such as TFLite.""" + """Abstract base class representing a Model Format such as TFLite. + + .. deprecated:: + Firebase ML is deprecated and will be shut down on June 15, 2027. + To host custom models, you must migrate to another solution. You can use Cloud Storage for + Firebase as an alternative for hosting custom models. For more info, see + https://firebase.google.com/docs/ml/migrate-to-cloud-storage + """ def as_dict(self, for_upload=False): """Returns a serializable representation of the object.""" raise NotImplementedError @@ -351,10 +455,18 @@ def as_dict(self, for_upload=False): class TFLiteFormat(ModelFormat): """Model format representing a TFLite model. + .. deprecated:: + Firebase ML is deprecated and will be shut down on June 15, 2027. + To host custom models, you must migrate to another solution. You can use Cloud Storage for + Firebase as an alternative for hosting custom models. For more info, see + https://firebase.google.com/docs/ml/migrate-to-cloud-storage + Args: model_source: A TFLiteModelSource sub class. Specifies the details of the model source. """ def __init__(self, model_source=None): + if not _is_internal_call(): + warnings.warn(_DEPRECATION_WARNING, DeprecationWarning, stacklevel=2) self._data = {} self._model_source = None @@ -412,7 +524,14 @@ def as_dict(self, for_upload=False): class TFLiteModelSource: - """Abstract base class representing a model source for TFLite format models.""" + """Abstract base class representing a model source for TFLite format models. + + .. deprecated:: + Firebase ML is deprecated and will be shut down on June 15, 2027. + To host custom models, you must migrate to another solution. You can use Cloud Storage for + Firebase as an alternative for hosting custom models. For more info, see + https://firebase.google.com/docs/ml/migrate-to-cloud-storage + """ def as_dict(self, for_upload=False): """Returns a serializable representation of the object.""" raise NotImplementedError @@ -466,11 +585,20 @@ def sign_uri(gcs_tflite_uri, app): class TFLiteGCSModelSource(TFLiteModelSource): - """TFLite model source representing a tflite model file stored in GCS.""" + """TFLite model source representing a tflite model file stored in GCS. + + .. deprecated:: + Firebase ML is deprecated and will be shut down on June 15, 2027. + To host custom models, you must migrate to another solution. You can use Cloud Storage for + Firebase as an alternative for hosting custom models. For more info, see + https://firebase.google.com/docs/ml/migrate-to-cloud-storage + """ _STORAGE_CLIENT = _CloudStorageClient() def __init__(self, gcs_tflite_uri, app=None): + if not _is_internal_call(): + warnings.warn(_DEPRECATION_WARNING, DeprecationWarning, stacklevel=2) self._app = app self._gcs_tflite_uri = _validate_gcs_tflite_uri(gcs_tflite_uri) @@ -600,12 +728,20 @@ def as_dict(self, for_upload=False): class ListModelsPage: """Represents a page of models in a Firebase project. + .. deprecated:: + Firebase ML is deprecated and will be shut down on June 15, 2027. + To host custom models, you must migrate to another solution. You can use Cloud Storage for + Firebase as an alternative for hosting custom models. For more info, see + https://firebase.google.com/docs/ml/migrate-to-cloud-storage + Provides methods for traversing the models included in this page, as well as retrieving subsequent pages of models. The iterator returned by ``iterate_all()`` can be used to iterate through all the models in the Firebase project starting from this page. """ def __init__(self, list_models_func, list_filter, page_size, page_token, app): + if not _is_internal_call(): + warnings.warn(_DEPRECATION_WARNING, DeprecationWarning, stacklevel=2) self._list_models_func = list_models_func self._list_filter = list_filter self._page_size = page_size diff --git a/firebase_admin/project_management.py b/firebase_admin/project_management.py index 73c100d3..74696dfb 100644 --- a/firebase_admin/project_management.py +++ b/firebase_admin/project_management.py @@ -541,11 +541,9 @@ def list_ios_apps(self): def _list_apps(self, platform_resource_name, app_class): """Lists all the Android or iOS apps within the Firebase project.""" - path = ( - f'/v1beta1/projects/{self._project_id}/{platform_resource_name}?pageSize=' - f'{_ProjectManagementService.MAXIMUM_LIST_APPS_PAGE_SIZE}' - ) - response = self._make_request('get', path) + path = f'/v1beta1/projects/{self._project_id}/{platform_resource_name}' + params = {'pageSize': _ProjectManagementService.MAXIMUM_LIST_APPS_PAGE_SIZE} + response = self._make_request('get', path, params=params) apps_list = [] while True: apps = response.get('apps') @@ -556,12 +554,11 @@ def _list_apps(self, platform_resource_name, app_class): if not next_page_token: break # Retrieve the next page of apps. - path = ( - f'/v1beta1/projects/{self._project_id}/{platform_resource_name}' - f'?pageToken={next_page_token}' - f'&pageSize={_ProjectManagementService.MAXIMUM_LIST_APPS_PAGE_SIZE}' - ) - response = self._make_request('get', path) + params = { + 'pageToken': next_page_token, + 'pageSize': _ProjectManagementService.MAXIMUM_LIST_APPS_PAGE_SIZE, + } + response = self._make_request('get', path, params=params) return apps_list def create_android_app(self, package_name, display_name=None): @@ -652,12 +649,12 @@ def delete_sha_certificate(self, certificate_to_delete): path = f'/v1beta1/{name}' self._make_request('delete', path) - def _make_request(self, method, url, json=None): - body, _ = self._body_and_response(method, url, json) + def _make_request(self, method, url, json=None, params=None): + body, _ = self._body_and_response(method, url, json=json, params=params) return body - def _body_and_response(self, method, url, json=None): + def _body_and_response(self, method, url, json=None, params=None): try: - return self._client.body_and_response(method=method, url=url, json=json) + return self._client.body_and_response(method=method, url=url, json=json, params=params) except requests.exceptions.RequestException as error: raise _utils.handle_platform_error_from_requests(error) diff --git a/firebase_admin/tenant_mgt.py b/firebase_admin/tenant_mgt.py index 9e713d98..f9f78d58 100644 --- a/firebase_admin/tenant_mgt.py +++ b/firebase_admin/tenant_mgt.py @@ -246,9 +246,7 @@ def __init__(self, app): def auth_for_tenant(self, tenant_id): """Gets an Auth Client instance scoped to the given tenant ID.""" - if not isinstance(tenant_id, str) or not tenant_id: - raise ValueError( - f'Invalid tenant ID: {tenant_id}. Tenant ID must be a non-empty string.') + _auth_utils.validate_tenant_id(tenant_id) with self.lock: if tenant_id in self.tenant_clients: @@ -260,9 +258,7 @@ def auth_for_tenant(self, tenant_id): def get_tenant(self, tenant_id): """Gets the tenant corresponding to the given ``tenant_id``.""" - if not isinstance(tenant_id, str) or not tenant_id: - raise ValueError( - f'Invalid tenant ID: {tenant_id}. Tenant ID must be a non-empty string.') + _auth_utils.validate_tenant_id(tenant_id) try: body = self.client.body('get', f'/tenants/{tenant_id}') @@ -292,8 +288,7 @@ def update_tenant( self, tenant_id, display_name=None, allow_password_sign_up=None, enable_email_link_sign_in=None): """Updates the specified tenant with the given parameters.""" - if not isinstance(tenant_id, str) or not tenant_id: - raise ValueError('Tenant ID must be a non-empty string.') + _auth_utils.validate_tenant_id(tenant_id) payload = {} if display_name is not None: @@ -319,9 +314,7 @@ def update_tenant( def delete_tenant(self, tenant_id): """Deletes the tenant corresponding to the given ``tenant_id``.""" - if not isinstance(tenant_id, str) or not tenant_id: - raise ValueError( - f'Invalid tenant ID: {tenant_id}. Tenant ID must be a non-empty string.') + _auth_utils.validate_tenant_id(tenant_id) try: self.client.request('delete', f'/tenants/{tenant_id}') diff --git a/integration/test_ml.py b/integration/test_ml.py index ea5b10be..7050ea63 100644 --- a/integration/test_ml.py +++ b/integration/test_ml.py @@ -26,6 +26,7 @@ from firebase_admin import ml from tests import testutils +pytestmark = pytest.mark.skip(reason='ML integration tests are skipped.') # pylint: disable=import-error, no-member try: diff --git a/snippets/firestore/firestore_pipelines.py b/snippets/firestore/firestore_pipelines.py new file mode 100644 index 00000000..338553a9 --- /dev/null +++ b/snippets/firestore/firestore_pipelines.py @@ -0,0 +1,3214 @@ +# Copyright 2025 Google LLC. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime +import firebase_admin +from firebase_admin import firestore + +default_app = firebase_admin.initialize_app() +client = firestore.client(default_app, "your-new-enterprise-database") + + +# pylint: disable=invalid-name + +def type_generic_functions(): + from google.cloud.firestore_v1.pipeline_expressions import Field, Constant + # [START type_function] + Field.of("rating").type() + # [END type_function] + + # [START concat_function] + Constant.of("Author ID: ").concat(Field.of("authorId")) + # [END concat_function] + + # [START length_function] + Field.of("tags").length() + # [END length_function] + + # [START reverse_function] + Field.of("tags").array_reverse() + # [END reverse_function] + +def query_explain(): + # [START query_explain] + from google.cloud.firestore import Query, FieldFilter + from google.cloud.firestore_v1.query_profile import PipelineExplainOptions + + results = client.collection("cities") \ + .where(filter=FieldFilter("capital", "==", True)) \ + .execute(explain_options=PipelineExplainOptions()) + stats = results.explain_stats + print(stats.get_text()) + # [END query_explain] + + +def pipeline_concepts(): + # [START pipeline_concepts] + from google.cloud.firestore_v1.pipeline_expressions import Field + + pipeline = ( + client.pipeline() + .collection("cities") + .where(Field.of("population").greater_than(100_000)) + .sort(Field.of("name").ascending()) + .limit(10) + ) + # [END pipeline_concepts] + print(pipeline) + + +def basic_read(): + # [START basic_read] + pipeline = client.pipeline().collection("users") + for result in pipeline.execute(): + print(f"{result.id} => {result.data()}") + # [END basic_read] + + +def pipeline_initialization(): + # [START pipeline_initialization] + firestore_client = firestore.client(default_app, "your-new-enterprise-database") + pipeline = firestore_client.pipeline() + # [END pipeline_initialization] + print(pipeline) + + +def field_vs_constants(): + # [START field_or_constant] + from google.cloud.firestore_v1.pipeline_expressions import Field, Constant + + pipeline = ( + client.pipeline() + .collection("cities") + .where(Field.of("name").equal(Constant.of("Toronto"))) + ) + # [END field_or_constant] + print(pipeline) + + +def input_stages(): + # [START input_stages] + # Return all restaurants in San Francisco + results = client.pipeline().collection("cities/sf/restaurants").execute() + + # Return all restaurants + results = client.pipeline().collection_group("restaurants").execute() + + # Return all documents across all collections in the database (the entire database) + results = client.pipeline().database().execute() + + # Batch read of 3 documents + results = ( + client.pipeline() + .documents( + client.collection("cities").document("SF"), + client.collection("cities").document("DC"), + client.collection("cities").document("NY"), + ) + .execute() + ) + # [END input_stages] + for result in results: + print(result) + + +def where_pipeline(): + # [START pipeline_where] + from google.cloud.firestore_v1.pipeline_expressions import And, Field + + results = ( + client.pipeline() + .collection("books") + .where(Field.of("rating").equal(5)) + .where(Field.of("published").less_than(1900)) + .execute() + ) + + results = ( + client.pipeline() + .collection("books") + .where(And(Field.of("rating").equal(5), Field.of("published").less_than(1900))) + .execute() + ) + # [END pipeline_where] + for result in results: + print(result) + + +def aggregate_groups(): + # [START aggregate_groups] + from google.cloud.firestore_v1.pipeline_expressions import Field + + results = ( + client.pipeline() + .collection("books") + .aggregate( + Field.of("rating").average().as_("avg_rating"), groups=[Field.of("genre")] + ) + .execute() + ) + # [END aggregate_groups] + for result in results: + print(result) + + +def aggregate_distinct(): + # [START aggregate_distinct] + from google.cloud.firestore_v1.pipeline_expressions import Field + + results = ( + client.pipeline() + .collection("books") + .distinct(Field.of("author").to_upper().as_("author"), "genre") + .execute() + ) + # [END aggregate_distinct] + for result in results: + print(result) + + +def sort(): + # [START sort] + from google.cloud.firestore_v1.pipeline_expressions import Field + + results = ( + client.pipeline() + .collection("books") + .sort(Field.of("release_date").descending(), Field.of("author").ascending()) + .execute() + ) + # [END sort] + for result in results: + print(result) + + +def sort_comparison(): + # [START sort_comparison] + from google.cloud.firestore import Query + from google.cloud.firestore_v1.pipeline_expressions import Field + + query = ( + client.collection("cities") + .order_by("state") + .order_by("population", direction=Query.DESCENDING) + ) + + pipeline = ( + client.pipeline() + .collection("books") + .sort(Field.of("release_date").descending(), Field.of("author").ascending()) + ) + # [END sort_comparison] + print(query) + print(pipeline) + + +def functions_example(): + # [START functions_example] + from google.cloud.firestore_v1.pipeline_expressions import Field + + # Type 1: Scalar (for use in non-aggregation stages) + # Example: Return the min store price for each book. + results = ( + client.pipeline() + .collection("books") + .select( + Field.of("current").logical_minimum(Field.of("updated")).as_("price_min") + ) + .execute() + ) + + # Type 2: Aggregation (for use in aggregate stages) + # Example: Return the min price of all books. + results = ( + client.pipeline() + .collection("books") + .aggregate(Field.of("price").minimum().as_("min_price")) + .execute() + ) + # [END functions_example] + for result in results: + print(result) + + +def creating_indexes(): + # [START query_example] + from google.cloud.firestore_v1.pipeline_expressions import Field + + results = ( + client.pipeline() + .collection("books") + .where(Field.of("published").less_than(1900)) + .where(Field.of("genre").equal("Science Fiction")) + .where(Field.of("rating").greater_than(4.3)) + .sort(Field.of("published").descending()) + .execute() + ) + # [END query_example] + for result in results: + print(result) + + +def sparse_indexes(): + # [START sparse_index_example] + from google.cloud.firestore_v1.pipeline_expressions import Field + + results = ( + client.pipeline() + .collection("books") + .where(Field.of("category").like("%fantasy%")) + .execute() + ) + # [END sparse_index_example] + for result in results: + print(result) + + +def sparse_indexes2(): + # [START sparse_index_example_2] + from google.cloud.firestore_v1.pipeline_expressions import Field + + results = ( + client.pipeline() + .collection("books") + .sort(Field.of("release_date").ascending()) + .execute() + ) + # [END sparse_index_example_2] + for result in results: + print(result) + + +def covered_query(): + # [START covered_query] + from google.cloud.firestore_v1.pipeline_expressions import Field + + results = ( + client.pipeline() + .collection("books") + .where(Field.of("category").like("%fantasy%")) + .where(Field.of("title").exists()) + .where(Field.of("author").exists()) + .select("title", "author") + .execute() + ) + # [END covered_query] + for result in results: + print(result) + + +def pagination(): + # [START pagination_not_supported_preview] + from google.cloud.firestore_v1.pipeline_expressions import Field + + # Existing pagination via `start_at()` + query = ( + client.collection("cities") + .order_by("population") + .start_at({"population": 1_000_000}) + ) + + # Private preview workaround using pipelines + pipeline = ( + client.pipeline() + .collection("cities") + .where(Field.of("population").greater_than_or_equal(1_000_000)) + .sort(Field.of("population").descending()) + ) + # [END pagination_not_supported_preview] + print(query) + print(pipeline) + + +def collection_stage(): + # [START collection_example] + from google.cloud.firestore_v1.pipeline_expressions import Field + + results = ( + client.pipeline() + .collection("users/bob/games") + .sort(Field.of("name").ascending()) + .execute() + ) + # [END collection_example] + for result in results: + print(result) + + +def collection_group_stage(): + # [START collection_group_example] + from google.cloud.firestore_v1.pipeline_expressions import Field + + results = ( + client.pipeline() + .collection_group("games") + .sort(Field.of("name").ascending()) + .execute() + ) + # [END collection_group_example] + for result in results: + print(result) + + +def database_stage(): + # [START database_example] + from google.cloud.firestore_v1.pipeline_expressions import Count + + # Count all documents in the database + results = client.pipeline().database().aggregate(Count().as_("total")).execute() + # [END database_example] + for result in results: + print(result) + + +def documents_stage(): + # [START documents_example] + results = ( + client.pipeline() + .documents( + client.collection("cities").document("SF"), + client.collection("cities").document("DC"), + client.collection("cities").document("NY"), + ) + .execute() + ) + # [END documents_example] + for result in results: + print(result) + + +def replace_with_stage(): + # [START initial_data] + client.collection("cities").document("SF").set( + { + "name": "San Francisco", + "population": 800_000, + "location": {"country": "USA", "state": "California"}, + } + ) + client.collection("cities").document("TO").set( + { + "name": "Toronto", + "population": 3_000_000, + "province": "ON", + "location": {"country": "Canada", "province": "Ontario"}, + } + ) + client.collection("cities").document("NY").set( + { + "name": "New York", + "population": 8_500_000, + "location": {"country": "USA", "state": "New York"}, + } + ) + client.collection("cities").document("AT").set( + { + "name": "Atlantis", + } + ) + # [END initial_data] + + # [START full_replace] + from google.cloud.firestore_v1.pipeline_expressions import Field + + names = ( + client.pipeline() + .collection("cities") + .replace_with(Field.of("location")) + .execute() + ) + # [END full_replace] + + # [START map_merge_overwrite] + # unsupported in client SDKs for now + # [END map_merge_overwrite] + for name in names: + print(name) + + +def sample_stage(): + # [START sample_example] + # Get a sample of 100 documents in a database + results = client.pipeline().database().sample(100).execute() + + # Randomly shuffle a list of 3 documents + results = ( + client.pipeline() + .documents( + client.collection("cities").document("SF"), + client.collection("cities").document("NY"), + client.collection("cities").document("DC"), + ) + .sample(3) + .execute() + ) + # [END sample_example] + for result in results: + print(result) + + +def sample_percent(): + # [START sample_percent] + from google.cloud.firestore_v1.pipeline_stages import SampleOptions + + # Get a sample of on average 50% of the documents in the database + results = ( + client.pipeline().database().sample(SampleOptions.percentage(0.5)).execute() + ) + # [END sample_percent] + for result in results: + print(result) + + +def union_stage(): + # [START union_stage] + from google.cloud.firestore_v1.pipeline_expressions import Field + + results = ( + client.pipeline() + .collection("cities/SF/restaurants") + .where(Field.of("type").equal("Chinese")) + .union( + client.pipeline() + .collection("cities/NY/restaurants") + .where(Field.of("type").equal("Italian")) + ) + .where(Field.of("rating").greater_than_or_equal(4.5)) + .sort(Field.of("__name__").descending()) + .execute() + ) + # [END union_stage] + for result in results: + print(result) + + +def union_stage_stable(): + # [START union_stage_stable] + from google.cloud.firestore_v1.pipeline_expressions import Field + + results = ( + client.pipeline() + .collection("cities/SF/restaurants") + .where(Field.of("type").equal("Chinese")) + .union( + client.pipeline() + .collection("cities/NY/restaurants") + .where(Field.of("type").equal("Italian")) + ) + .where(Field.of("rating").greater_than_or_equal(4.5)) + .sort(Field.of("__name__").descending()) + .execute() + ) + # [END union_stage_stable] + for result in results: + print(result) + + +def unnest_stage(): + # [START unnest_stage] + from google.cloud.firestore_v1.pipeline_expressions import Field + from google.cloud.firestore_v1.pipeline_stages import UnnestOptions + + results = ( + client.pipeline() + .database() + .unnest( + Field.of("arrayField").as_("unnestedArrayField"), + options=UnnestOptions(index_field="index"), + ) + .execute() + ) + # [END unnest_stage] + for result in results: + print(result) + + +def unnest_stage_empty_or_non_array(): + # [START unnest_edge_cases] + from google.cloud.firestore_v1.pipeline_expressions import Field + from google.cloud.firestore_v1.pipeline_stages import UnnestOptions + + # Input + # { "identifier" : 1, "neighbors": [ "Alice", "Cathy" ] } + # { "identifier" : 2, "neighbors": [] } + # { "identifier" : 3, "neighbors": "Bob" } + + results = ( + client.pipeline() + .database() + .unnest( + Field.of("neighbors").as_("unnestedNeighbors"), + options=UnnestOptions(index_field="index"), + ) + .execute() + ) + + # Output + # { "identifier": 1, "neighbors": [ "Alice", "Cathy" ], + # "unnestedNeighbors": "Alice", "index": 0 } + # { "identifier": 1, "neighbors": [ "Alice", "Cathy" ], + # "unnestedNeighbors": "Cathy", "index": 1 } + # { "identifier": 3, "neighbors": "Bob", "index": null} + # [END unnest_edge_cases] + for result in results: + print(result) + + +def count_function(): + # [START count_function] + from google.cloud.firestore_v1.pipeline_expressions import Count + + # Total number of books in the collection + count_all = ( + client.pipeline().collection("books").aggregate(Count().as_("count")).execute() + ) + + # Number of books with nonnull `ratings` field + count_field = ( + client.pipeline() + .collection("books") + .aggregate(Count("ratings").as_("count")) + .execute() + ) + # [END count_function] + for result in count_all: + print(result) + for result in count_field: + print(result) + + +def count_if_function(): + # [START count_if] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("books") + .aggregate(Field.of("rating").greater_than(4).count_if().as_("filteredCount")) + .execute() + ) + # [END count_if] + for res in result: + print(res) + + +def count_distinct_function(): + # [START count_distinct] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("books") + .aggregate(Field.of("author").count_distinct().as_("unique_authors")) + .execute() + ) + # [END count_distinct] + for res in result: + print(res) + + +def sum_function(): + # [START sum_function] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("cities") + .aggregate(Field.of("population").sum().as_("totalPopulation")) + .execute() + ) + # [END sum_function] + for res in result: + print(res) + + +def avg_function(): + # [START avg_function] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("cities") + .aggregate(Field.of("population").average().as_("averagePopulation")) + .execute() + ) + # [END avg_function] + for res in result: + print(res) + + +def min_function(): + # [START min_function] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("books") + .aggregate(Field.of("price").minimum().as_("minimumPrice")) + .execute() + ) + # [END min_function] + for res in result: + print(res) + + +def max_function(): + # [START max_function] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("books") + .aggregate(Field.of("price").maximum().as_("maximumPrice")) + .execute() + ) + # [END max_function] + for res in result: + print(res) + + +def add_function(): + # [START add_function] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("books") + .select(Field.of("soldBooks").add(Field.of("unsoldBooks")).as_("totalBooks")) + .execute() + ) + # [END add_function] + for res in result: + print(res) + + +def subtract_function(): + # [START subtract_function] + from google.cloud.firestore_v1.pipeline_expressions import Field + + store_credit = 7 + result = ( + client.pipeline() + .collection("books") + .select(Field.of("price").subtract(store_credit).as_("totalCost")) + .execute() + ) + # [END subtract_function] + for res in result: + print(res) + + +def multiply_function(): + # [START multiply_function] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("books") + .select(Field.of("price").multiply(Field.of("soldBooks")).as_("revenue")) + .execute() + ) + # [END multiply_function] + for res in result: + print(res) + + +def divide_function(): + # [START divide_function] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("books") + .select(Field.of("ratings").divide(Field.of("soldBooks")).as_("reviewRate")) + .execute() + ) + # [END divide_function] + for res in result: + print(res) + + +def mod_function(): + # [START mod_function] + from google.cloud.firestore_v1.pipeline_expressions import Field + + display_capacity = 1000 + result = ( + client.pipeline() + .collection("books") + .select(Field.of("unsoldBooks").mod(display_capacity).as_("warehousedBooks")) + .execute() + ) + # [END mod_function] + for res in result: + print(res) + + +def ceil_function(): + # [START ceil_function] + from google.cloud.firestore_v1.pipeline_expressions import Field + + books_per_shelf = 100 + result = ( + client.pipeline() + .collection("books") + .select( + Field.of("unsoldBooks") + .divide(books_per_shelf) + .ceil() + .as_("requiredShelves") + ) + .execute() + ) + # [END ceil_function] + for res in result: + print(res) + + +def floor_function(): + # [START floor_function] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("books") + .add_fields( + Field.of("wordCount").divide(Field.of("pages")).floor().as_("wordsPerPage") + ) + .execute() + ) + # [END floor_function] + for res in result: + print(res) + + +def round_function(): + # [START round_function] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("books") + .select( + Field.of("soldBooks") + .multiply(Field.of("price")) + .round() + .as_("partialRevenue") + ) + .aggregate(Field.of("partialRevenue").sum().as_("totalRevenue")) + .execute() + ) + # [END round_function] + for res in result: + print(res) + + +def pow_function(): + # [START pow_function] + from google.cloud.firestore_v1.pipeline_expressions import Field + + googleplexLat = 37.4221 + googleplexLng = -122.0853 + result = ( + client.pipeline() + .collection("cities") + .add_fields( + Field.of("lat") + .subtract(googleplexLat) + .multiply(111) # km per degree + .pow(2) + .as_("latitudeDifference"), + Field.of("lng") + .subtract(googleplexLng) + .multiply(111) # km per degree + .pow(2) + .as_("longitudeDifference"), + ) + .select( + Field.of("latitudeDifference") + .add(Field.of("longitudeDifference")) + .sqrt() + # Inaccurate for large distances or close to poles + .as_("approximateDistanceToGoogle") + ) + .execute() + ) + # [END pow_function] + for res in result: + print(res) + + +def sqrt_function(): + # [START sqrt_function] + from google.cloud.firestore_v1.pipeline_expressions import Field + + googleplexLat = 37.4221 + googleplexLng = -122.0853 + result = ( + client.pipeline() + .collection("cities") + .add_fields( + Field.of("lat") + .subtract(googleplexLat) + .multiply(111) # km per degree + .pow(2) + .as_("latitudeDifference"), + Field.of("lng") + .subtract(googleplexLng) + .multiply(111) # km per degree + .pow(2) + .as_("longitudeDifference"), + ) + .select( + Field.of("latitudeDifference") + .add(Field.of("longitudeDifference")) + .sqrt() + # Inaccurate for large distances or close to poles + .as_("approximateDistanceToGoogle") + ) + .execute() + ) + # [END sqrt_function] + for res in result: + print(res) + + +def exp_function(): + # [START exp_function] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("books") + .select(Field.of("rating").exp().as_("expRating")) + .execute() + ) + # [END exp_function] + for res in result: + print(res) + + +def ln_function(): + # [START ln_function] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("books") + .select(Field.of("rating").ln().as_("lnRating")) + .execute() + ) + # [END ln_function] + for res in result: + print(res) + + +def log_function(): + # [START log_function] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("books") + .select(Field.of("rating").log(2).as_("log2Rating")) + .execute() + ) + # [END log_function] + for res in result: + print(res) + + +def array_concat(): + # [START array_concat] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("books") + .select(Field.of("genre").array_concat(Field.of("subGenre")).as_("allGenres")) + .execute() + ) + # [END array_concat] + for res in result: + print(res) + + +def array_contains(): + # [START array_contains] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("books") + .select(Field.of("genre").array_contains("mystery").as_("isMystery")) + .execute() + ) + # [END array_contains] + for res in result: + print(res) + + +def array_contains_all(): + # [START array_contains_all] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("books") + .select( + Field.of("genre") + .array_contains_all(["fantasy", "adventure"]) + .as_("isFantasyAdventure") + ) + .execute() + ) + # [END array_contains_all] + for res in result: + print(res) + + +def array_contains_any(): + # [START array_contains_any] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("books") + .select( + Field.of("genre") + .array_contains_any(["fantasy", "nonfiction"]) + .as_("isMysteryOrFantasy") + ) + .execute() + ) + # [END array_contains_any] + for res in result: + print(res) + + +def array_length(): + # [START array_length] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("books") + .select(Field.of("genre").array_length().as_("genreCount")) + .execute() + ) + # [END array_length] + for res in result: + print(res) + + +def array_reverse(): + # [START array_reverse] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("books") + .select(Field.of("genre").array_reverse().as_("reversedGenres")) + .execute() + ) + # [END array_reverse] + for res in result: + print(res) + + +def equal_function(): + # [START equal_function] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("books") + .select(Field.of("rating").equal(5).as_("hasPerfectRating")) + .execute() + ) + # [END equal_function] + for res in result: + print(res) + + +def greater_than_function(): + # [START greater_than] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("books") + .select(Field.of("rating").greater_than(4).as_("hasHighRating")) + .execute() + ) + # [END greater_than] + for res in result: + print(res) + + +def greater_than_or_equal_to_function(): + # [START greater_or_equal] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("books") + .select( + Field.of("published") + .greater_than_or_equal(1900) + .as_("publishedIn20thCentury") + ) + .execute() + ) + # [END greater_or_equal] + for res in result: + print(res) + + +def less_than_function(): + # [START less_than] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("books") + .select(Field.of("published").less_than(1923).as_("isPublicDomainProbably")) + .execute() + ) + # [END less_than] + for res in result: + print(res) + + +def less_than_or_equal_to_function(): + # [START less_or_equal] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("books") + .select(Field.of("rating").less_than_or_equal(2).as_("hasBadRating")) + .execute() + ) + # [END less_or_equal] + for res in result: + print(res) + + +def not_equal_function(): + # [START not_equal] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("books") + .select(Field.of("title").not_equal("1984").as_("not1984")) + .execute() + ) + # [END not_equal] + for res in result: + print(res) + + +def exists_function(): + # [START exists_function] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("books") + .select(Field.of("rating").exists().as_("hasRating")) + .execute() + ) + # [END exists_function] + for res in result: + print(res) + + +def and_function(): + # [START and_function] + from google.cloud.firestore_v1.pipeline_expressions import Field, And + + result = ( + client.pipeline() + .collection("books") + .select( + And( + Field.of("rating").greater_than(4), Field.of("price").less_than(10) + ).as_("under10Recommendation") + ) + .execute() + ) + # [END and_function] + for res in result: + print(res) + + +def or_function(): + # [START or_function] + from google.cloud.firestore_v1.pipeline_expressions import Field, And, Or + + result = ( + client.pipeline() + .collection("books") + .select( + Or( + Field.of("genre").equal("Fantasy"), + Field.of("tags").array_contains("adventure"), + ).as_("matchesSearchFilters") + ) + .execute() + ) + # [END or_function] + for res in result: + print(res) + + +def xor_function(): + # [START xor_function] + from google.cloud.firestore_v1.pipeline_expressions import Field, Xor + + result = ( + client.pipeline() + .collection("books") + .select( + Xor( + [ + Field.of("tags").array_contains("magic"), + Field.of("tags").array_contains("nonfiction"), + ] + ).as_("matchesSearchFilters") + ) + .execute() + ) + # [END xor_function] + for res in result: + print(res) + + +def not_function(): + # [START not_function] + from google.cloud.firestore_v1.pipeline_expressions import Field, Not + + result = ( + client.pipeline() + .collection("books") + .select(Not(Field.of("tags").array_contains("nonfiction")).as_("isFiction")) + .execute() + ) + # [END not_function] + for res in result: + print(res) + + +def cond_function(): + # [START cond_function] + from google.cloud.firestore_v1.pipeline_expressions import ( + Field, + Constant, + Conditional, + ) + + result = ( + client.pipeline() + .collection("books") + .select( + Field.of("tags") + .array_concat( + Conditional( + Field.of("pages").greater_than(100), + Constant.of("longRead"), + Constant.of("shortRead"), + ) + ) + .as_("extendedTags") + ) + .execute() + ) + # [END cond_function] + for res in result: + print(res) + + +def equal_any_function(): + # [START eq_any] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("books") + .select( + Field.of("genre") + .equal_any(["Science Fiction", "Psychological Thriller"]) + .as_("matchesGenreFilters") + ) + .execute() + ) + # [END eq_any] + for res in result: + print(res) + + +def not_equal_any_function(): + # [START not_eq_any] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("books") + .select( + Field.of("author") + .not_equal_any(["George Orwell", "F. Scott Fitzgerald"]) + .as_("byExcludedAuthors") + ) + .execute() + ) + # [END not_eq_any] + for res in result: + print(res) + + +def max_logical_function(): + # [START max_logical_function] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("books") + .select(Field.of("rating").logical_maximum(1).as_("flooredRating")) + .execute() + ) + # [END max_logical_function] + for res in result: + print(res) + + +def min_logical_function(): + # [START min_logical_function] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("books") + .select(Field.of("rating").logical_minimum(5).as_("cappedRating")) + .execute() + ) + # [END min_logical_function] + for res in result: + print(res) + + +def map_get_function(): + # [START map_get] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("books") + .select(Field.of("awards").map_get("pulitzer").as_("hasPulitzerAward")) + .execute() + ) + # [END map_get] + for res in result: + print(res) + + +def byte_length_function(): + # [START byte_length] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("books") + .select(Field.of("title").byte_length().as_("titleByteLength")) + .execute() + ) + # [END byte_length] + for res in result: + print(res) + + +def char_length_function(): + # [START char_length] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("books") + .select(Field.of("title").char_length().as_("titleCharLength")) + .execute() + ) + # [END char_length] + for res in result: + print(res) + + +def starts_with_function(): + # [START starts_with] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("books") + .select( + Field.of("title").starts_with("The").as_("needsSpecialAlphabeticalSort") + ) + .execute() + ) + # [END starts_with] + for res in result: + print(res) + + +def ends_with_function(): + # [START ends_with] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("inventory/devices/laptops") + .select(Field.of("name").ends_with("16 inch").as_("16InLaptops")) + .execute() + ) + # [END ends_with] + for res in result: + print(res) + + +def like_function(): + # [START like] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("books") + .select(Field.of("genre").like("%Fiction").as_("anyFiction")) + .execute() + ) + # [END like] + for res in result: + print(res) + + +def regex_contains_function(): + # [START regex_contains] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("documents") + .select( + Field.of("title") + .regex_contains("Firestore (Enterprise|Standard)") + .as_("isFirestoreRelated") + ) + .execute() + ) + # [END regex_contains] + for res in result: + print(res) + + +def regex_match_function(): + # [START regex_match] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("documents") + .select( + Field.of("title") + .regex_match("Firestore (Enterprise|Standard)") + .as_("isFirestoreExactly") + ) + .execute() + ) + # [END regex_match] + for res in result: + print(res) + + +def str_concat_function(): + # [START str_concat] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("books") + .select( + Field.of("title") + .concat(" by ", Field.of("author")) + .as_("fullyQualifiedTitle") + ) + .execute() + ) + # [END str_concat] + for res in result: + print(res) + + +def str_contains_function(): + # [START string_contains] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("articles") + .select(Field.of("body").string_contains("Firestore").as_("isFirestoreRelated")) + .execute() + ) + # [END string_contains] + for res in result: + print(res) + + +def to_upper_function(): + # [START to_upper] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("authors") + .select(Field.of("name").to_upper().as_("uppercaseName")) + .execute() + ) + # [END to_upper] + for res in result: + print(res) + + +def to_lower_function(): + # [START to_lower] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("authors") + .select(Field.of("genre").to_lower().equal("fantasy").as_("isFantasy")) + .execute() + ) + # [END to_lower] + for res in result: + print(res) + + +def substr_function(): + # [START substr_function] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("books") + .where(Field.of("title").starts_with("The ")) + .select(Field.of("title").substring(4).as_("titleWithoutLeadingThe")) + .execute() + ) + # [END substr_function] + for res in result: + print(res) + + +def str_reverse_function(): + # [START str_reverse] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("books") + .select(Field.of("name").string_reverse().as_("reversedName")) + .execute() + ) + # [END str_reverse] + for res in result: + print(res) + + +def str_trim_function(): + # [START trim_function] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("books") + .select(Field.of("name").trim().as_("whitespaceTrimmedName")) + .execute() + ) + # [END trim_function] + for res in result: + print(res) + + +def str_replace_function(): + # not yet supported until GA + pass + + +def str_split_function(): + # not yet supported until GA + pass + + +def unix_micros_to_timestamp_function(): + # [START unix_micros_timestamp] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("documents") + .select( + Field.of("createdAtMicros") + .unix_micros_to_timestamp() + .as_("createdAtString") + ) + .execute() + ) + # [END unix_micros_timestamp] + for res in result: + print(res) + + +def unix_millis_to_timestamp_function(): + # [START unix_millis_timestamp] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("documents") + .select( + Field.of("createdAtMillis") + .unix_millis_to_timestamp() + .as_("createdAtString") + ) + .execute() + ) + # [END unix_millis_timestamp] + for res in result: + print(res) + + +def unix_seconds_to_timestamp_function(): + # [START unix_seconds_timestamp] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("documents") + .select( + Field.of("createdAtSeconds") + .unix_seconds_to_timestamp() + .as_("createdAtString") + ) + .execute() + ) + # [END unix_seconds_timestamp] + for res in result: + print(res) + + +def timestamp_add_function(): + # [START timestamp_add] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("documents") + .select(Field.of("createdAt").timestamp_add("day", 3653).as_("expiresAt")) + .execute() + ) + # [END timestamp_add] + for res in result: + print(res) + + +def timestamp_sub_function(): + # [START timestamp_sub] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("documents") + .select( + Field.of("expiresAt") + .timestamp_subtract("day", 14) + .as_("sendWarningTimestamp") + ) + .execute() + ) + # [END timestamp_sub] + for res in result: + print(res) + + +def timestamp_to_unix_micros_function(): + # [START timestamp_unix_micros] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("documents") + .select(Field.of("dateString").timestamp_to_unix_micros().as_("unixMicros")) + .execute() + ) + # [END timestamp_unix_micros] + for res in result: + print(res) + + +def timestamp_to_unix_millis_function(): + # [START timestamp_unix_millis] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("documents") + .select(Field.of("dateString").timestamp_to_unix_millis().as_("unixMillis")) + .execute() + ) + # [END timestamp_unix_millis] + for res in result: + print(res) + + +def timestamp_to_unix_seconds_function(): + # [START timestamp_unix_seconds] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("documents") + .select(Field.of("dateString").timestamp_to_unix_seconds().as_("unixSeconds")) + .execute() + ) + # [END timestamp_unix_seconds] + for res in result: + print(res) + + +def cosine_distance_function(): + # [START cosine_distance] + from google.cloud.firestore_v1.pipeline_expressions import Field + from google.cloud.firestore_v1.vector import Vector + + sample_vector = Vector([0.0, 1.0, 2.0, 3.0, 4.0, 5.0]) + result = ( + client.pipeline() + .collection("books") + .select( + Field.of("embedding").cosine_distance(sample_vector).as_("cosineDistance") + ) + .execute() + ) + # [END cosine_distance] + for res in result: + print(res) + + +def dot_product_function(): + # [START dot_product] + from google.cloud.firestore_v1.pipeline_expressions import Field + from google.cloud.firestore_v1.vector import Vector + + sample_vector = Vector([0.0, 1.0, 2.0, 3.0, 4.0, 5.0]) + result = ( + client.pipeline() + .collection("books") + .select(Field.of("embedding").dot_product(sample_vector).as_("dotProduct")) + .execute() + ) + # [END dot_product] + for res in result: + print(res) + + +def euclidean_distance_function(): + # [START euclidean_distance] + from google.cloud.firestore_v1.pipeline_expressions import Field + from google.cloud.firestore_v1.vector import Vector + + sample_vector = Vector([0.0, 1.0, 2.0, 3.0, 4.0, 5.0]) + result = ( + client.pipeline() + .collection("books") + .select( + Field.of("embedding") + .euclidean_distance(sample_vector) + .as_("euclideanDistance") + ) + .execute() + ) + # [END euclidean_distance] + for res in result: + print(res) + + +def vector_length_function(): + # [START vector_length] + from google.cloud.firestore_v1.pipeline_expressions import Field + + result = ( + client.pipeline() + .collection("books") + .select(Field.of("embedding").vector_length().as_("vectorLength")) + .execute() + ) + # [END vector_length] + for res in result: + print(res) + + +def stages_expressions_example(): + # [START stages_expressions_example] + from google.cloud.firestore_v1.pipeline_expressions import Field, Constant + from google.cloud import firestore + + trailing_30_days = ( + Constant.of(firestore.SERVER_TIMESTAMP) + .unix_millis_to_timestamp() + .timestamp_subtract("day", 30) + ) + snapshot = ( + client.pipeline() + .collection("productViews") + .where(Field.of("viewedAt").greater_than(trailing_30_days)) + .aggregate(Field.of("productId").count_distinct().as_("uniqueProductViews")) + .execute() + ) + # [END stages_expressions_example] + for result in snapshot: + print(result) + + +# https://cloud.google.com/firestore/docs/pipeline/stages/transformation/where +def create_where_data(): + # [START create_where_data] + client.collection("cities").document("SF").set( + {"name": "San Francisco", "state": "CA", "country": "USA", "population": 870000} + ) + client.collection("cities").document("LA").set( + {"name": "Los Angeles", "state": "CA", "country": "USA", "population": 3970000} + ) + client.collection("cities").document("NY").set( + {"name": "New York", "state": "NY", "country": "USA", "population": 8530000} + ) + client.collection("cities").document("TOR").set( + {"name": "Toronto", "state": None, "country": "Canada", "population": 2930000} + ) + client.collection("cities").document("MEX").set( + {"name": "Mexico City", "state": None, "country": "Mexico", "population": 9200000} + ) + # [END create_where_data] + + +def where_equality_example(): + # [START where_equality_example] + from google.cloud.firestore_v1.pipeline_expressions import Field + + cities = ( + client.pipeline() + .collection("cities") + .where(Field.of("state").equal("CA")) + .execute() + ) + # [END where_equality_example] + for city in cities: + print(city) + + +def where_multiple_stages_example(): + # [START where_multiple_stages] + from google.cloud.firestore_v1.pipeline_expressions import Field + + cities = ( + client.pipeline() + .collection("cities") + .where(Field.of("location.country").equal("USA")) + .where(Field.of("population").greater_than(500000)) + .execute() + ) + # [END where_multiple_stages] + for city in cities: + print(city) + + +def where_complex_example(): + # [START where_complex] + from google.cloud.firestore_v1.pipeline_expressions import Field, Or, And + + cities = ( + client.pipeline() + .collection("cities") + .where( + Or( + Field.of("name").like("San%"), + And( + Field.of("location.state").char_length().greater_than(7), + Field.of("location.country").equal("USA"), + ), + ) + ) + .execute() + ) + # [END where_complex] + for city in cities: + print(city) + + +def where_stage_order_example(): + # [START where_stage_order] + from google.cloud.firestore_v1.pipeline_expressions import Field + + cities = ( + client.pipeline() + .collection("cities") + .limit(10) + .where(Field.of("location.country").equal("USA")) + .execute() + ) + # [END where_stage_order] + for city in cities: + print(city) + + +def where_having_example(): + # [START where_having_example] + from google.cloud.firestore_v1.pipeline_expressions import Field + + cities = ( + client.pipeline() + .collection("cities") + .aggregate( + Field.of("population").sum().as_("totalPopulation"), + groups=[Field.of("location.state")], + ) + .where(Field.of("totalPopulation").greater_than(10000000)) + .execute() + ) + # [END where_having_example] + for city in cities: + print(city) + + +# https://cloud.google.com/firestore/docs/pipeline/stages/transformation/unnest +def unnest_syntax_example(): + # [START unnest_syntax] + from google.cloud.firestore_v1.pipeline_expressions import Field + from google.cloud.firestore_v1.pipeline_stages import UnnestOptions + + user_score = ( + client.pipeline() + .collection("users") + .unnest( + Field.of("scores").as_("userScore"), + options=UnnestOptions(index_field="attempt"), + ) + .execute() + ) + # [END unnest_syntax] + for score in user_score: + print(score) + + +def unnest_alias_index_data_example(): + # [START unnest_alias_index_data] + client.collection("users").add({"name": "foo", "scores": [5, 4], "userScore": 0}) + client.collection("users").add({"name": "bar", "scores": [1, 3], "attempt": 5}) + # [END unnest_alias_index_data] + + +def unnest_alias_index_example(): + # [START unnest_alias_index] + from google.cloud.firestore_v1.pipeline_expressions import Field + from google.cloud.firestore_v1.pipeline_stages import UnnestOptions + + user_score = ( + client.pipeline() + .collection("users") + .unnest( + Field.of("scores").as_("userScore"), + options=UnnestOptions(index_field="attempt"), + ) + .execute() + ) + # [END unnest_alias_index] + for score in user_score: + print(score) + + +def unnest_non_array_data_example(): + # [START unnest_nonarray_data] + client.collection("users").add({"name": "foo", "scores": 1}) + client.collection("users").add({"name": "bar", "scores": None}) + client.collection("users").add({"name": "qux", "scores": {"backupScores": 1}}) + # [END unnest_nonarray_data] + + +def unnest_non_array_example(): + # [START unnest_nonarray] + from google.cloud.firestore_v1.pipeline_expressions import Field + from google.cloud.firestore_v1.pipeline_stages import UnnestOptions + + user_score = ( + client.pipeline() + .collection("users") + .unnest( + Field.of("scores").as_("userScore"), + options=UnnestOptions(index_field="attempt"), + ) + .execute() + ) + # [END unnest_nonarray] + for score in user_score: + print(score) + + +def unnest_empty_array_data_example(): + # [START unnest_empty_array_data] + client.collection("users").add({"name": "foo", "scores": [5, 4]}) + client.collection("users").add({"name": "bar", "scores": []}) + # [END unnest_empty_array_data] + + +def unnest_empty_array_example(): + # [START unnest_empty_array] + from google.cloud.firestore_v1.pipeline_expressions import Field + from google.cloud.firestore_v1.pipeline_stages import UnnestOptions + + user_score = ( + client.pipeline() + .collection("users") + .unnest( + Field.of("scores").as_("userScore"), + options=UnnestOptions(index_field="attempt"), + ) + .execute() + ) + # [END unnest_empty_array] + for score in user_score: + print(score) + + +def unnest_preserve_empty_array_example(): + # [START unnest_preserve_empty_array] + from google.cloud.firestore_v1.pipeline_expressions import ( + Array, + Conditional, + Expression, + Field, + ) + from google.cloud.firestore_v1.pipeline_stages import UnnestOptions + + user_score = ( + client.pipeline() + .collection("users") + .unnest( + Conditional( + Field.of("scores").equal(Array([])), + Array([Field.of("scores")]), + Field.of("scores"), + ).as_("userScore"), + options=UnnestOptions(index_field="attempt"), + ) + .execute() + ) + # [END unnest_preserve_empty_array] + for score in user_score: + print(score) + + +def unnest_nested_data_example(): + # [START unnest_nested_data] + client.collection("users").add( + { + "name": "foo", + "record": [ + {"scores": [5, 4], "avg": 4.5}, + {"scores": [1, 3], "old_avg": 2}, + ], + } + ) + # [END unnest_nested_data] + + +def unnest_nested_example(): + # [START unnest_nested] + from google.cloud.firestore_v1.pipeline_expressions import Field + from google.cloud.firestore_v1.pipeline_stages import UnnestOptions + + user_score = ( + client.pipeline() + .collection("users") + .unnest(Field.of("record").as_("record")) + .unnest( + Field.of("record.scores").as_("userScore"), + options=UnnestOptions(index_field="attempt"), + ) + .execute() + ) + # [END unnest_nested] + for score in user_score: + print(score) + + +# https://cloud.google.com/firestore/docs/pipeline/stages/transformation/sample +def sample_syntax_example(): + # [START sample_syntax] + from google.cloud.firestore_v1.pipeline_stages import SampleOptions + + sampled = client.pipeline().database().sample(50).execute() + + sampled = ( + client.pipeline().database().sample(options=SampleOptions.percentage(0.5)).execute() + ) + # [END sample_syntax] + for result in sampled: + print(result) + + +def sample_documents_data_example(): + # [START sample_documents_data] + client.collection("cities").document("SF").set( + {"name": "San Francisco", "state": "California"} + ) + client.collection("cities").document("NYC").set( + {"name": "New York City", "state": "New York"} + ) + client.collection("cities").document("CHI").set( + {"name": "Chicago", "state": "Illinois"} + ) + # [END sample_documents_data] + + +def sample_documents_example(): + # [START sample_documents] + sampled = client.pipeline().collection("cities").sample(1).execute() + # [END sample_documents] + for result in sampled: + print(result) + + +def sample_all_documents_example(): + # [START sample_all_documents] + sampled = client.pipeline().collection("cities").sample(5).execute() + # [END sample_all_documents] + for result in sampled: + print(result) + + +def sample_percentage_data_example(): + # [START sample_percentage_data] + client.collection("cities").document("SF").set( + {"name": "San Francsico", "state": "California"} + ) + client.collection("cities").document("NYC").set( + {"name": "New York City", "state": "New York"} + ) + client.collection("cities").document("CHI").set( + {"name": "Chicago", "state": "Illinois"} + ) + client.collection("cities").document("ATL").set( + {"name": "Atlanta", "state": "Georgia"} + ) + # [END sample_percentage_data] + + +def sample_percentage_example(): + # [START sample_percentage] + from google.cloud.firestore_v1.pipeline_stages import SampleOptions + + sampled = ( + client.pipeline() + .collection("cities") + .sample(options=SampleOptions.percentage(0.5)) + .execute() + ) + # [END sample_percentage] + for result in sampled: + print(result) + + +# https://cloud.google.com/firestore/docs/pipeline/stages/transformation/sort +def sort_syntax_example(): + # [START sort_syntax] + from google.cloud.firestore_v1.pipeline_expressions import Field + + results = ( + client.pipeline() + .collection("cities") + .sort(Field.of("population").ascending()) + .execute() + ) + # [END sort_syntax] + for result in results: + print(result) + + +def sort_syntax_example2(): + # [START sort_syntax_2] + from google.cloud.firestore_v1.pipeline_expressions import Field + + results = ( + client.pipeline() + .collection("cities") + .sort(Field.of("name").char_length().ascending()) + .execute() + ) + # [END sort_syntax_2] + for result in results: + print(result) + + +def sort_document_id_example(): + # [START sort_document_id] + from google.cloud.firestore_v1.pipeline_expressions import Field + + results = ( + client.pipeline() + .collection("cities") + .sort(Field.of("country").ascending(), Field.of("__name__").ascending()) + .execute() + ) + # [END sort_document_id] + for result in results: + print(result) + + +# https://cloud.google.com/firestore/docs/pipeline/stages/transformation/select +def select_syntax_example(): + # [START select_syntax] + from google.cloud.firestore_v1.pipeline_expressions import Field + + names = ( + client.pipeline() + .collection("cities") + .select( + Field.of("name").string_concat(", ", Field.of("location.country")).as_("name"), + "population", + ) + .execute() + ) + # [END select_syntax] + for name in names: + print(name) + + +def select_position_data_example(): + # [START select_position_data] + client.collection("cities").document("SF").set( + { + "name": "San Francisco", + "population": 800000, + "location": {"country": "USA", "state": "California"}, + } + ) + client.collection("cities").document("TO").set( + { + "name": "Toronto", + "population": 3000000, + "location": {"country": "Canada", "province": "Ontario"}, + } + ) + # [END select_position_data] + + +def select_position_example(): + # [START select_position] + from google.cloud.firestore_v1.pipeline_expressions import Field + + names = ( + client.pipeline() + .collection("cities") + .where(Field.of("location.country").equal("Canada")) + .select( + Field.of("name").string_concat(", ", Field.of("location.country")).as_("name"), + "population", + ) + .execute() + ) + # [END select_position] + for name in names: + print(name) + + +def select_bad_position_example(): + # [START select_bad_position] + from google.cloud.firestore_v1.pipeline_expressions import Field + + names = ( + client.pipeline() + .collection("cities") + .select( + Field.of("name").string_concat(", ", Field.of("location.country")).as_("name"), + "population", + ) + .where(Field.of("location.country").equal("Canada")) + .execute() + ) + # [END select_bad_position] + for name in names: + print(name) + + +def select_nested_data_example(): + # [START select_nested_data] + client.collection("cities").document("SF").set( + { + "name": "San Francisco", + "population": 800000, + "location": {"country": "USA", "state": "California"}, + "landmarks": ["Golden Gate Bridge", "Alcatraz"], + } + ) + client.collection("cities").document("TO").set( + { + "name": "Toronto", + "population": 3000000, + "province": "ON", + "location": {"country": "Canada", "province": "Ontario"}, + "landmarks": ["CN Tower", "Casa Loma"], + } + ) + client.collection("cities").document("AT").set({"name": "Atlantis", "population": None}) + # [END select_nested_data] + + +def select_nested_example(): + # [START select_nested] + from google.cloud.firestore_v1.pipeline_expressions import Field + + locations = ( + client.pipeline() + .collection("cities") + .select( + Field.of("name").as_("city"), + Field.of("location.country").as_("country"), + Field.of("landmarks").array_get(0).as_("topLandmark"), + ) + .execute() + ) + # [END select_nested] + for location in locations: + print(location) + + +# https://cloud.google.com/firestore/docs/pipeline/stages/transformation/remove_fields +def remove_fields_syntax_example(): + # [START remove_fields_syntax] + results = ( + client.pipeline() + .collection("cities") + .remove_fields("population", "location.state") + .execute() + ) + # [END remove_fields_syntax] + for result in results: + print(result) + + +def remove_fields_nested_data_example(): + # [START remove_fields_nested_data] + client.collection("cities").document("SF").set( + {"name": "San Francisco", "location": {"country": "USA", "state": "California"}} + ) + client.collection("cities").document("TO").set( + {"name": "Toronto", "location": {"country": "Canada", "province": "Ontario"}} + ) + # [END remove_fields_nested_data] + + +def remove_fields_nested_example(): + # [START remove_fields_nested] + results = ( + client.pipeline().collection("cities").remove_fields("location.state").execute() + ) + # [END remove_fields_nested] + for result in results: + print(result) + + +# https://cloud.google.com/firestore/docs/pipeline/stages/transformation/limit +def limit_syntax_example(): + # [START limit_syntax] + results = client.pipeline().collection("cities").limit(10).execute() + # [END limit_syntax] + for result in results: + print(result) + + +# https://cloud.google.com/firestore/docs/pipeline/stages/transformation/find_nearest +def find_nearest_syntax_example(): + # [START find_nearest_syntax] + from google.cloud.firestore_v1.vector import Vector + from google.cloud.firestore_v1.base_vector_query import DistanceMeasure + + results = ( + client.pipeline() + .collection("cities") + .find_nearest( + field="embedding", + vector_value=Vector([1.5, 2.345]), + distance_measure=DistanceMeasure.EUCLIDEAN, + ) + .execute() + ) + # [END find_nearest_syntax] + for result in results: + print(result) + + +def find_nearest_limit_example(): + # [START find_nearest_limit] + from google.cloud.firestore_v1.vector import Vector + from google.cloud.firestore_v1.base_vector_query import DistanceMeasure + + results = ( + client.pipeline() + .collection("cities") + .find_nearest( + field="embedding", + vector_value=Vector([1.5, 2.345]), + distance_measure=DistanceMeasure.EUCLIDEAN, + limit=10, + ) + .execute() + ) + # [END find_nearest_limit] + for result in results: + print(result) + + +def find_nearest_distance_data_example(): + # [START find_nearest_distance_data] + from google.cloud.firestore_v1.vector import Vector + + client.collection("cities").document("SF").set( + {"name": "San Francisco", "embedding": Vector([1.0, -1.0])} + ) + client.collection("cities").document("TO").set( + {"name": "Toronto", "embedding": Vector([5.0, -10.0])} + ) + client.collection("cities").document("AT").set( + {"name": "Atlantis", "embedding": Vector([2.0, -4.0])} + ) + # [END find_nearest_distance_data] + + +def find_nearest_distance_example(): + # [START find_nearest_distance] + from google.cloud.firestore_v1.vector import Vector + from google.cloud.firestore_v1.base_vector_query import DistanceMeasure + + results = ( + client.pipeline() + .collection("cities") + .find_nearest( + field="embedding", + vector_value=Vector([1.3, 2.345]), + distance_measure=DistanceMeasure.EUCLIDEAN, + distance_field="computedDistance", + ) + .execute() + ) + # [END find_nearest_distance] + for result in results: + print(result) + + +# https://cloud.google.com/firestore/docs/pipeline/stages/transformation/offset +def offset_syntax_example(): + # [START offset_syntax] + results = client.pipeline().collection("cities").offset(10).execute() + # [END offset_syntax] + for result in results: + print(result) + + +# https://cloud.google.com/firestore/docs/pipeline/stages/transformation/add_fields +def add_fields_syntax_example(): + # [START add_fields_syntax] + from google.cloud.firestore_v1.pipeline_expressions import Field + + results = ( + client.pipeline() + .collection("users") + .add_fields( + Field.of("firstName").string_concat(" ", Field.of("lastName")).as_("fullName") + ) + .execute() + ) + # [END add_fields_syntax] + for result in results: + print(result) + + +def add_fields_overlap_example(): + # [START add_fields_overlap] + from google.cloud.firestore_v1.pipeline_expressions import Field + + results = ( + client.pipeline() + .collection("users") + .add_fields(Field.of("age").abs().as_("age")) + .add_fields(Field.of("age").add(10).as_("age")) + .execute() + ) + # [END add_fields_overlap] + for result in results: + print(result) + + +def add_fields_nesting_example(): + # [START add_fields_nesting] + from google.cloud.firestore_v1.pipeline_expressions import Field + + results = ( + client.pipeline() + .collection("users") + .add_fields(Field.of("address.city").to_lower().as_("address.city")) + .execute() + ) + # [END add_fields_nesting] + for result in results: + print(result) + + +# https://cloud.google.com/firestore/docs/pipeline/stages/input/collection +def collection_input_syntax_example(): + # [START collection_input_syntax] + results = client.pipeline().collection("cities/SF/departments").execute() + # [END collection_input_syntax] + for result in results: + print(result) + + +def collection_input_example_data(): + # [START collection_input_data] + client.collection("cities").document("SF").set( + {"name": "San Francsico", "state": "California"} + ) + client.collection("cities").document("NYC").set( + {"name": "New York City", "state": "New York"} + ) + client.collection("cities").document("CHI").set( + {"name": "Chicago", "state": "Illinois"} + ) + client.collection("states").document("CA").set({"name": "California"}) + # [END collection_input_data] + + +def collection_input_example(): + # [START collection_input] + from google.cloud.firestore_v1.pipeline_expressions import Field + + results = ( + client.pipeline().collection("cities").sort(Field.of("name").ascending()).execute() + ) + # [END collection_input] + for result in results: + print(result) + + +def subcollection_input_example_data(): + # [START subcollection_input_data] + client.collection("cities/SF/departments").document("building").set( + {"name": "SF Building Deparment", "employees": 750} + ) + client.collection("cities/NY/departments").document("building").set( + {"name": "NY Building Deparment", "employees": 1000} + ) + client.collection("cities/CHI/departments").document("building").set( + {"name": "CHI Building Deparment", "employees": 900} + ) + client.collection("cities/NY/departments").document("finance").set( + {"name": "NY Finance Deparment", "employees": 1200} + ) + # [END subcollection_input_data] + + +def subcollection_input_example(): + # [START subcollection_input] + from google.cloud.firestore_v1.pipeline_expressions import Field + + results = ( + client.pipeline() + .collection("cities/NY/departments") + .sort(Field.of("employees").ascending()) + .execute() + ) + # [END subcollection_input] + for result in results: + print(result) + + +# https://cloud.google.com/firestore/docs/pipeline/stages/input/collection_group +def collection_group_input_syntax_example(): + # [START collection_group_input_syntax] + results = client.pipeline().collection_group("departments").execute() + # [END collection_group_input_syntax] + for result in results: + print(result) + + +def collection_group_input_example_data(): + # [START collection_group_data] + client.collection("cities/SF/departments").document("building").set( + {"name": "SF Building Deparment", "employees": 750} + ) + client.collection("cities/NY/departments").document("building").set( + {"name": "NY Building Deparment", "employees": 1000} + ) + client.collection("cities/CHI/departments").document("building").set( + {"name": "CHI Building Deparment", "employees": 900} + ) + client.collection("cities/NY/departments").document("finance").set( + {"name": "NY Finance Deparment", "employees": 1200} + ) + # [END collection_group_data] + + +def collection_group_input_example(): + # [START collection_group_input] + from google.cloud.firestore_v1.pipeline_expressions import Field + + results = ( + client.pipeline() + .collection_group("departments") + .sort(Field.of("employees").ascending()) + .execute() + ) + # [END collection_group_input] + for result in results: + print(result) + + +# https://cloud.google.com/firestore/docs/pipeline/stages/input/database +def database_input_syntax_example(): + # [START database_syntax] + results = client.pipeline().database().execute() + # [END database_syntax] + for result in results: + print(result) + + +def database_input_syntax_example_data(): + # [START database_input_data] + client.collection("cities").document("SF").set( + {"name": "San Francsico", "state": "California", "population": 800000} + ) + client.collection("states").document("CA").set( + {"name": "California", "population": 39000000} + ) + client.collection("countries").document("USA").set( + {"name": "United States of America", "population": 340000000} + ) + # [END database_input_data] + + +def database_input_example(): + # [START database_input] + from google.cloud.firestore_v1.pipeline_expressions import Field + + results = ( + client.pipeline() + .database() + .sort(Field.of("population").ascending()) + .execute() + ) + # [END database_input] + for result in results: + print(result) + + +# https://cloud.google.com/firestore/docs/pipeline/stages/input/documents +def document_input_syntax_example(): + # [START document_input_syntax] + results = ( + client.pipeline() + .documents( + [ + client.collection("cities").document("SF"), + client.collection("cities").document("NY"), + ] + ) + .execute() + ) + # [END document_input_syntax] + for result in results: + print(result) + + +def document_input_example_data(): + # [START document_input_data] + client.collection("cities").document("SF").set( + {"name": "San Francsico", "state": "California"} + ) + client.collection("cities").document("NYC").set( + {"name": "New York City", "state": "New York"} + ) + client.collection("cities").document("CHI").set( + {"name": "Chicago", "state": "Illinois"} + ) + # [END document_input_data] + + +def document_input_example(): + # [START document_input] + from google.cloud.firestore_v1.pipeline_expressions import Field + + results = ( + client.pipeline() + .documents( + [ + client.collection("cities").document("SF"), + client.collection("cities").document("NYC"), + ] + ) + .sort(Field.of("name").ascending()) + .execute() + ) + # [END document_input] + for result in results: + print(result) + + +# https://cloud.google.com/firestore/docs/pipeline/stages/transformation/union +def union_syntax_example(): + # [START union_syntax] + results = ( + client.pipeline() + .collection("cities/SF/restaurants") + .union(client.pipeline().collection("cities/NYC/restaurants")) + .execute() + ) + # [END union_syntax] + for result in results: + print(result) + + +# https://cloud.google.com/firestore/docs/pipeline/stages/transformation/aggregate +def aggregate_syntax_example(): + # [START aggregate_syntax] + from google.cloud.firestore_v1.pipeline_expressions import Count, Field + + cities = ( + client.pipeline() + .collection("cities") + .aggregate( + Count().as_("total"), + Field.of("population").average().as_("averagePopulation"), + ) + .execute() + ) + # [END aggregate_syntax] + for city in cities: + print(city) + + +def aggregate_group_syntax(): + # [START aggregate_group_syntax] + from google.cloud.firestore_v1.pipeline_expressions import Field, Count + + result = ( + client.pipeline() + .collection_group("cities") + .aggregate( + Count().as_("cities"), + Field.of("population").sum().as_("totalPopulation"), + groups=[Field.of("location.state").as_("state")], + ) + .execute() + ) + # [END aggregate_group_syntax] + for res in result: + print(res) + + +def aggregate_example_data(): + # [START aggregate_data] + client.collection("cities").document("SF").set( + {"name": "San Francisco", "state": "CA", "country": "USA", "population": 870000} + ) + client.collection("cities").document("LA").set( + {"name": "Los Angeles", "state": "CA", "country": "USA", "population": 3970000} + ) + client.collection("cities").document("NY").set( + {"name": "New York", "state": "NY", "country": "USA", "population": 8530000} + ) + client.collection("cities").document("TOR").set( + {"name": "Toronto", "state": None, "country": "Canada", "population": 2930000} + ) + client.collection("cities").document("MEX").set( + {"name": "Mexico City", "state": None, "country": "Mexico", "population": 9200000} + ) + # [END aggregate_data] + + +def aggregate_without_group_example(): + # [START aggregate_without_group] + from google.cloud.firestore_v1.pipeline_expressions import Field, Count + + cities = ( + client.pipeline() + .collection("cities") + .aggregate( + Count().as_("total"), + Field.of("population").average().as_("averagePopulation"), + ) + .execute() + ) + # [END aggregate_without_group] + for city in cities: + print(city) + + +def aggregate_group_example(): + # [START aggregate_group_example] + from google.cloud.firestore_v1.pipeline_expressions import Field, Count + + cities = ( + client.pipeline() + .collection("cities") + .aggregate( + Count().as_("numberOfCities"), + Field.of("population").maximum().as_("maxPopulation"), + groups=["country", "state"], + ) + .execute() + ) + # [END aggregate_group_example] + for city in cities: + print(city) + + +def aggregate_group_complex_example(): + # [START aggregate_group_complex] + from google.cloud.firestore_v1.pipeline_expressions import Field + + cities = ( + client.pipeline() + .collection("cities") + .aggregate( + Field.of("population").sum().as_("totalPopulation"), + groups=[Field.of("state").equal(None).as_("stateIsNull")], + ) + .execute() + ) + # [END aggregate_group_complex] + for city in cities: + print(city) + + +# https://cloud.google.com/firestore/docs/pipeline/stages/transformation/distinct +def distinct_syntax_example(): + # [START distinct_syntax] + from google.cloud.firestore_v1.pipeline_expressions import Field + + cities = client.pipeline().collection("cities").distinct("country").execute() + + cities = ( + client.pipeline() + .collection("cities") + .distinct(Field.of("state").to_lower().as_("normalizedState"), "country") + .execute() + ) + # [END distinct_syntax] + for city in cities: + print(city) + + +def distinct_example_data(): + # [START distinct_data] + client.collection("cities").document("SF").set( + {"name": "San Francisco", "state": "CA", "country": "USA"} + ) + client.collection("cities").document("LA").set( + {"name": "Los Angeles", "state": "CA", "country": "USA"} + ) + client.collection("cities").document("NY").set( + {"name": "New York", "state": "NY", "country": "USA"} + ) + client.collection("cities").document("TOR").set( + {"name": "Toronto", "state": None, "country": "Canada"} + ) + client.collection("cities").document("MEX").set( + {"name": "Mexico City", "state": None, "country": "Mexico"} + ) + # [END distinct_data] + + +def distinct_example(): + # [START distinct_example] + cities = client.pipeline().collection("cities").distinct("country").execute() + # [END distinct_example] + for city in cities: + print(city) + + +def distinct_expressions_example(): + # [START distinct_expressions] + from google.cloud.firestore_v1.pipeline_expressions import Field + + cities = ( + client.pipeline() + .collection("cities") + .distinct(Field.of("state").to_lower().as_("normalizedState"), "country") + .execute() + ) + # [END distinct_expressions] + for city in cities: + print(city) + + + +def search_basic_query_data(): + # [START search_basic_query_data] + client.collection("Restaurants").add( + {"name": "Waffle Place", "description": "A cozy place for fresh waffles."} + ) + # [END search_basic_query_data] + + +def search_basic(): + # [START search_basic] + from google.cloud.firestore_v1.pipeline_expressions import DocumentMatches + + results = ( + client.pipeline() + .collection("restaurants") + .search(DocumentMatches("waffles")) + .execute() + ) + # [END search_basic] + for result in results: + print(result) + + +def search_exact_match_data(): + # [START search_exact_match_data] + client.collection("Restaurants").add( + {"name": "Waffle Place", "description": "A cozy place for fresh belgian waffles."} + ) + # [END search_exact_match_data] + + +def search_exact(): + # [START search_exact] + from google.cloud.firestore_v1.pipeline_expressions import DocumentMatches + + results = ( + client.pipeline() + .collection("restaurants") + .search(DocumentMatches('"belgian waffles"')) + .execute() + ) + # [END search_exact] + for result in results: + print(result) + + +def search_two_terms_data(): + # [START search_two_terms_data] + client.collection("Restaurants").add( + { + "name": "Morning Diner", + "description": "Start your day with waffles and eggs.", + } + ) + # [END search_two_terms_data] + + +def search_two_terms(): + # [START search_two_terms] + from google.cloud.firestore_v1.pipeline_expressions import DocumentMatches + + results = ( + client.pipeline() + .collection("restaurants") + .search(DocumentMatches("waffles eggs")) + .execute() + ) + # [END search_two_terms] + for result in results: + print(result) + + +def search_exclude_term_data(): + # [START search_exclude_term_data] + client.collection("Restaurants").add( + {"name": "City Coffee", "description": "Premium coffee and pastries."} + ) + # [END search_exclude_term_data] + + +def search_exclude(): + # [START search_exclude] + from google.cloud.firestore_v1.pipeline_expressions import DocumentMatches + + results = ( + client.pipeline() + .collection("restaurants") + .search(DocumentMatches("-waffles")) + .execute() + ) + # [END search_exclude] + for result in results: + print(result) + + +def search_score_data(): + # [START search_score_data] + client.collection("Restaurants").add( + {"name": "The Waffle Hub", "description": "Everything waffles!"} + ) + # [END search_score_data] + + +def search_score(): + # [START search_score] + from google.cloud.firestore_v1.pipeline_expressions import DocumentMatches, Score + from google.cloud.firestore_v1.pipeline_stages import SearchOptions + + results = ( + client.pipeline() + .collection("restaurants") + .search( + SearchOptions( + query=DocumentMatches("menu:waffles"), + add_fields=[Score().as_("score")], + ) + ) + .execute() + ) + # [END search_score] + for result in results: + print(result) + + +def search_score_sort(): + # [START search_score_sort] + from google.cloud.firestore_v1.pipeline_expressions import DocumentMatches, Score + from google.cloud.firestore_v1.pipeline_stages import SearchOptions + + results = ( + client.pipeline() + .collection("restaurants") + .search( + SearchOptions( + query=DocumentMatches("waffles"), + sort=Score().descending(), + ) + ) + .execute() + ) + # [END search_score_sort] + for result in results: + print(result) + + +def search_geospatial(): + # [START search_geospatial] + from google.cloud.firestore_v1.pipeline_expressions import DocumentMatches + + results = ( + client.pipeline() + .collection("restaurants") + .search(DocumentMatches('"belgian waffles"')) + .execute() + ) + # [END search_geospatial] + for result in results: + print(result) + + +def define_stage_data(): + # [START define_stage_data] + client.collection("Authors").document("author_123").set( + {"id": "author_123", "name": "Jane Austen"} + ) + # [END define_stage_data] + + +def define_example(): + # [START define_example] + from google.cloud.firestore_v1.pipeline_expressions import Field, Variable + + result = ( + client.pipeline() + .collection("authors") + .define(Field.of("id").as_("currentAuthorId")) + .add_fields( + client.pipeline() + .collection("books") + .where(Field.of("author_id").equal(Variable("currentAuthorId"))) + .aggregate(Field.of("rating").average().as_("avgRating")) + .to_scalar_expression() + .as_("averageBookRating") + ) + .execute() + ) + # [END define_example] + for r in result: + print(r) + + +def to_array_expression_stage_data(): + # [START to_array_expression_stage_data] + client.collection("Projects").document("project_1").set( + {"id": "project_1", "name": "Alpha Build"} + ) + tasks = [ + {"project_id": "project_1", "title": "System Architecture"}, + {"project_id": "project_1", "title": "Database Schema Design"}, + ] + for task in tasks: + client.collection("Tasks").add(task) + # [END to_array_expression_stage_data] + + +def to_array_example(): + # [START to_array_example] + from google.cloud.firestore_v1.pipeline_expressions import Field, Variable + + results = ( + client.pipeline() + .collection("projects") + .define(Field.of("id").as_("parentId")) + .add_fields( + client.pipeline() + .collection("tasks") + .where(Field.of("project_id").equal(Variable("parentId"))) + .select(Field.of("title")) + .to_array_expression() + .as_("taskTitles") + ) + .execute() + ) + # [END to_array_example] + for result in results: + print(result) + + +def to_scalar_expression_stage_data(): + # [START to_scalar_expression_stage_data] + client.collection("Authors").document("author_202").set( + {"id": "author_202", "name": "Charles Dickens"} + ) + books = [ + {"author_id": "author_202", "title": "Great Expectations", "rating": 4.8}, + {"author_id": "author_202", "title": "Oliver Twist", "rating": 4.5}, + ] + for book in books: + client.collection("Books").add(book) + # [END to_scalar_expression_stage_data] + + +def to_scalar_example(): + # [START to_scalar_example] + from google.cloud.firestore_v1.pipeline_expressions import Field, Variable + + results = ( + client.pipeline() + .collection("authors") + .define(Field.of("id").as_("currentAuthorId")) + .add_fields( + client.pipeline() + .collection("books") + .where(Field.of("author_id").equal(Variable("currentAuthorId"))) + .aggregate(Field.of("rating").average().as_("avgRating")) + .to_scalar_expression() + .as_("averageBookRating") + ) + .execute() + ) + # [END to_scalar_example] + for result in results: + print(result) + + +def force_index_example(): + # [START force_index_example] + # Note: forceIndex is not currently supported in the Python SDK. + results = client.pipeline().collection_group("customers").limit(100).execute() + # [END force_index_example] + for result in results: + print(result) + + +def force_scan_example(): + # [START force_scan_example] + # Note: forceIndex="primary" is not currently supported in the Python SDK. + results = client.pipeline().collection_group("customers").limit(100).execute() + # [END force_scan_example] + for result in results: + print(result) + + +def update_dml_example_data(): + # [START update_dml_example_data] + client.collection("Users").document("userID").set( + {"id": "userID", "preferences": {}, "color": "#FFFFFF"} + ) + # [END update_dml_example_data] + + +def pipeline_update_example(): + # [START pipeline_update] + from google.cloud.firestore_v1.pipeline_expressions import Constant, Field, Not + + snapshot = ( + client.pipeline() + .collection_group("users") + .where(Not(Field.of("preferences.color").exists())) + .add_fields(Constant.of(None).as_("preferences.color")) + .remove_fields("color") + .update() + .execute() + ) + # [END pipeline_update] + print(snapshot) + + +def delete_dml_example_data(): + # [START delete_dml_example_data] + import datetime + + client.collection("Users").document("userID").set( + { + "id": "userID", + "address": {"country": "USA", "state": "CA"}, + "__create_time__": datetime.datetime.fromtimestamp( + 946684800000 / 1000, tz=datetime.timezone.utc + ), + } + ) + # [END delete_dml_example_data] + + +def pipeline_delete_example(): + # [START pipeline_delete] + from google.cloud.firestore_v1.pipeline_expressions import CurrentTimestamp, Field + + snapshot = ( + client.pipeline() + .collection_group("users") + .where(Field.of("address.country").equal("USA")) + .where( + Field.of("__create_time__") + .timestamp_add("day", 10) + .less_than(CurrentTimestamp()) + ) + .delete() + .execute() + ) + # [END pipeline_delete] + print(snapshot) diff --git a/snippets/firestore/pipeline.py b/snippets/firestore/pipeline.py new file mode 100644 index 00000000..971d09fd --- /dev/null +++ b/snippets/firestore/pipeline.py @@ -0,0 +1,346 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Pipeline snippets for Cloud Firestore.""" + +# pylint: disable=invalid-name + + +def load_test_data(db): + # [START pipeline_join_test_data] + # Load set of cities. + cities = db.collection("cities") + + cities.document("SF").set( + { + "name": "San Francisco", + "state": "CA", + "country": "USA", + } + ) + cities.document("LA").set( + { + "name": "Los Angeles", + "state": "CA", + "country": "USA", + } + ) + cities.document("DC").set( + { + "name": "Washington, D.C.", + "state": None, + "country": "USA", + } + ) + cities.document("TOK").set( + { + "name": "Tokyo", + "state": None, + "country": "Japan", + } + ) + + # Load restaurants in various cities. + sf_restaurants = db.collection("cities").document("SF").collection("restaurants") + la_restaurants = db.collection("cities").document("LA").collection("restaurants") + dc_restaurants = db.collection("cities").document("DC").collection("restaurants") + + rest1 = sf_restaurants.add( + { + "name": "Golden Gate Pizza", + "type": "pizza", + "owner_id": "Mario Rossi", + } + )[1] + rest2 = sf_restaurants.add( + { + "name": "Bay Area Burger", + "type": "burger", + "owner_id": "Sarah Jenkins", + } + )[1] + rest3 = sf_restaurants.add( + { + "name": "Sunset Taco", + "type": "mexican", + "owner_id": "Edward", + } + )[1] + + rest4 = la_restaurants.add( + { + "name": "Hollywood Sushi", + "type": "sushi", + "owner_id": "Ken Kenji", + } + )[1] + rest5 = la_restaurants.add( + { + "name": "Venice Pizza", + "type": "pizza", + "owner_id": "Luigi Romano", + } + )[1] + + rest6 = dc_restaurants.add( + { + "name": "Capitol Tacos", + "type": "mexican", + "owner_id": "Maria Garcia", + } + )[1] + rest7 = dc_restaurants.add( + { + "name": "Georgetown Coffee", + "type": "cafe", + "owner_id": "David Kim", + } + )[1] + + # Load collection of reviews. + reviews = db.collection("reviews") + + reviews.add({"restaurant": rest1, "rating": 5, "reviewer_id": "Alice"}) + reviews.add({"restaurant": rest1, "rating": 4, "reviewer_id": "Bob"}) + reviews.add({"restaurant": rest2, "rating": 4, "reviewer_id": "Charlie"}) + reviews.add({"restaurant": rest3, "rating": 5, "reviewer_id": "Diana"}) + reviews.add({"restaurant": rest3, "rating": 4, "reviewer_id": "Edward"}) + reviews.add({"restaurant": rest3, "rating": 4, "reviewer_id": "Fiona"}) + # rest4 has 0 reviews + reviews.add({"restaurant": rest5, "rating": 3, "reviewer_id": "George"}) + reviews.add({"restaurant": rest6, "rating": 5, "reviewer_id": "Hannah"}) + reviews.add({"restaurant": rest6, "rating": 4, "reviewer_id": "Ian"}) + reviews.add({"restaurant": rest7, "rating": 5, "reviewer_id": "Julia"}) + # [END pipeline_join_test_data] + + +def pipeline_join_lookup(db): + from google.cloud.firestore_v1.pipeline_expressions import Field, Variable + + # [START pipeline_join_lookup] + results = ( + db.pipeline() + .collection_group("reviews") + .define(Field.of("restaurant").as_("restaurant_name")) + .add_fields( + db.pipeline() + .collection_group("restaurants") + .where(Field.of("__name__").equal(Variable("restaurant_name"))) + .select("name", "type") + .to_scalar_expression() + .as_("restaurant") + ) + .execute() + ) + # [END pipeline_join_lookup] + return results + + +def pipeline_join_array(db): + from google.cloud.firestore_v1.pipeline_expressions import Field, Variable + + # [START pipeline_join_array] + results = ( + db.pipeline() + .collection_group("restaurants") + .where(Field.of("type").equal("pizza")) + .define(Field.of("__name__").as_("restaurant_name")) + .select( + Field.of("name"), + db.pipeline() + .collection_group("reviews") + .where(Field.of("restaurant").equal(Variable("restaurant_name"))) + .select("rating", "reviewer_id") + .to_array_expression() + .as_("reviews"), + ) + .execute() + ) + # [END pipeline_join_array] + return results + + +def pipeline_join_aggregate(db): + from google.cloud.firestore_v1.pipeline_expressions import Field, Variable + + # [START pipeline_join_aggregate] + results = ( + db.pipeline() + .collection_group("restaurants") + .where(Field.of("type").equal("pizza")) + .define(Field.of("__name__").as_("restaurant_name")) + .select( + Field.of("name"), + db.pipeline() + .collection_group("reviews") + .where(Field.of("restaurant").equal(Variable("restaurant_name"))) + .aggregate(Field.of("rating").average().as_("avg_rating")) + .to_scalar_expression() + .as_("avg_rating"), + ) + .execute() + ) + # [END pipeline_join_aggregate] + return results + + +def pipeline_join_limit(db): + from google.cloud.firestore_v1.pipeline_expressions import Field, Variable + + # [START pipeline_join_limit] + results = ( + db.pipeline() + .collection_group("restaurants") + .define(Field.of("__name__").as_("restaurant_name")) + .select( + Field.of("name"), + db.pipeline() + .collection_group("reviews") + .where(Field.of("restaurant").equal(Variable("restaurant_name"))) + .sort(Field.of("rating").descending()) + .limit(2) + .select("rating", "reviewer_id") + .to_array_expression() + .as_("top_reviews"), + ) + .execute() + ) + # [END pipeline_join_limit] + return results + + +def pipeline_join_subcollection(db): + from google.cloud.firestore_v1.pipeline_source import PipelineSource + + # [START pipeline_join_subcollection] + results = ( + db.pipeline() + .collection("cities") + .add_fields( + PipelineSource.subcollection("restaurants") + .to_array_expression() + .length() + .as_("restaurant_count") + ) + .execute() + ) + # [END pipeline_join_subcollection] + return results + + +def pipeline_join_multi_field(db): + from google.cloud.firestore_v1.pipeline_expressions import Count, Field, Variable + + # [START pipeline_join_multi_field] + results = ( + db.pipeline() + .collection_group("restaurants") + .define( + Field.of("owner_id").as_("owner_id"), + Field.of("__name__").as_("__name__"), + ) + .where( + db.pipeline() + .collection_group("reviews") + .where(Field.of("restaurant").equal(Variable("__name__"))) + .where(Field.of("reviewer_id").equal(Variable("owner_id"))) + .aggregate(Count().as_("c")) + .to_scalar_expression() + .greater_than(0) + ) + .execute() + ) + # [END pipeline_join_multi_field] + return results + + +def pipeline_join_anti(db): + from google.cloud.firestore_v1.pipeline_expressions import Count, Field, Variable + + # [START pipeline_join_anti] + results = ( + db.pipeline() + .collection_group("restaurants") + .define(Field.of("__name__").as_("restaurant_name")) + .where( + db.pipeline() + .collection_group("reviews") + .where(Field.of("restaurant").equal(Variable("restaurant_name"))) + .aggregate(Count().as_("review_count")) + .to_scalar_expression() + .equal(0) + ) + .execute() + ) + # [END pipeline_join_anti] + return results + + +def pipeline_join_unnest(db): + from google.cloud.firestore_v1.pipeline_expressions import Field, Variable + + # [START pipeline_join_unnest] + results = ( + db.pipeline() + .collection_group("restaurants") + .where(Field.of("type").equal("pizza")) + .define(Field.of("__name__").as_("restaurant_name")) + .unnest( + db.pipeline() + .collection_group("reviews") + .where(Field.of("restaurant").equal(Variable("restaurant_name"))) + .select("rating", "reviewer_id") + .to_array_expression(), + alias="review", + ) + .execute() + ) + # [END pipeline_join_unnest] + return results + + +def pipeline_join_uncorrelated(db): + from google.cloud.firestore_v1.pipeline_expressions import Field + + # [START pipeline_join_uncorrelated] + results = ( + db.pipeline() + .collection("reviews") + # Average review rating is 4.3 + .where( + Field.of("rating").greater_than( + db.pipeline() + .collection("reviews") + .aggregate(Field.of("rating").average().as_("avg")) + .to_scalar_expression() + ) + ) + .select("rating", "reviewer_id") + .execute() + ) + # [END pipeline_join_uncorrelated] + return results + + +def pipeline_force_table_scan(db): + # [START pipeline_force_table_scan] + # Force Planner to only do a Full-Table Scan + results = ( + db.pipeline() + .collection_group("customers") + .limit(100) + .execute() + ) + # [END pipeline_force_table_scan] + return results diff --git a/tests/test_app_check.py b/tests/test_app_check.py index e55ae39d..b88d1d6a 100644 --- a/tests/test_app_check.py +++ b/tests/test_app_check.py @@ -15,11 +15,12 @@ """Test cases for the firebase_admin.app_check module.""" import base64 import pytest +import requests from jwt import PyJWK, InvalidAudienceError, InvalidIssuerError from jwt import ExpiredSignatureError, InvalidSignatureError import firebase_admin -from firebase_admin import app_check +from firebase_admin import app_check, exceptions from tests import testutils NON_STRING_ARGS = [[], tuple(), {}, True, False, 1, 0] @@ -58,6 +59,15 @@ def setup_class(cls): def teardown_class(cls): testutils.cleanup_apps() + +@pytest.fixture +def mock_jwt(mocker): + """Mocks JWT decoding and JWKS key retrieval for a valid App Check token.""" + mocker.patch("jwt.decode", return_value=JWT_PAYLOAD_SAMPLE) + mocker.patch("jwt.PyJWKClient.get_signing_key_from_jwt", return_value=PyJWK(signing_key)) + mocker.patch("jwt.get_unverified_header", return_value=JWT_PAYLOAD_SAMPLE.get("headers")) + + class TestVerifyToken(TestBatch): def test_no_project_id(self): @@ -74,6 +84,12 @@ def test_verify_token_with_non_string_raises_error(self, token): expected = f'app check token "{token}" must be a string.' assert str(excinfo.value) == expected + @pytest.mark.parametrize('consume', [[], tuple(), {}, 1, 0, 'true', 'false', None]) + def test_verify_token_with_non_boolean_consume_raises_error(self, consume): + with pytest.raises(ValueError) as excinfo: + app_check.verify_token('app_check_token', consume=consume) + assert str(excinfo.value) == 'consume must be a boolean.' + def test_has_valid_token_headers(self): app = firebase_admin.get_app() app_check_service = app_check._get_app_check_service(app) @@ -221,10 +237,8 @@ def test_decode_and_verify_with_non_string_sub_raises_error(self, mocker): f'"{sub_number}" must be a string.') assert str(excinfo.value) == expected - def test_verify_token(self, mocker): - mocker.patch("jwt.decode", return_value=JWT_PAYLOAD_SAMPLE) - mocker.patch("jwt.PyJWKClient.get_signing_key_from_jwt", return_value=PyJWK(signing_key)) - mocker.patch("jwt.get_unverified_header", return_value=JWT_PAYLOAD_SAMPLE.get("headers")) + @pytest.mark.usefixtures("mock_jwt") + def test_verify_token(self): app = firebase_admin.get_app() payload = app_check.verify_token("encoded", app) @@ -273,3 +287,122 @@ def test_verify_token_with_incorrect_issuer_raises_error(self, mocker): expected = 'Token does not contain the correct "iss" (issuer).' assert str(excinfo.value) == expected + + @pytest.mark.usefixtures("mock_jwt") + @pytest.mark.parametrize('consume_kwargs', [{}, {'consume': False}]) + def test_verify_token_with_consume_false_makes_no_http_call( + self, mocker, consume_kwargs + ): + app = firebase_admin.get_app() + app_check_service = app_check._get_app_check_service(app) + mock_body = mocker.patch.object(app_check_service._http_client, "body") + + payload = app_check.verify_token("encoded", app=app, **consume_kwargs) + expected = JWT_PAYLOAD_SAMPLE.copy() + expected["app_id"] = APP_ID + assert payload == expected + assert 'already_consumed' not in payload + mock_body.assert_not_called() + + @pytest.mark.usefixtures("mock_jwt") + def test_verify_token_with_consume_true_not_consumed(self, mocker): + app = firebase_admin.get_app() + app_check_service = app_check._get_app_check_service(app) + mock_body = mocker.patch.object( + app_check_service._http_client, "body", return_value={"alreadyConsumed": False} + ) + + payload = app_check.verify_token("encoded", app=app, consume=True) + expected = JWT_PAYLOAD_SAMPLE.copy() + expected["app_id"] = APP_ID + expected["already_consumed"] = False + assert payload == expected + + expected_url = ( + f"https://firebaseappcheck.googleapis.com/v1beta/projects/{PROJECT_ID}" + ":verifyAppCheckToken" + ) + mock_body.assert_called_once_with( + "post", expected_url, json={"app_check_token": "encoded"} + ) + + @pytest.mark.usefixtures("mock_jwt") + def test_verify_token_with_consume_true_already_consumed(self, mocker): + app = firebase_admin.get_app() + app_check_service = app_check._get_app_check_service(app) + mock_body = mocker.patch.object( + app_check_service._http_client, "body", return_value={"alreadyConsumed": True} + ) + + payload = app_check.verify_token("encoded", app=app, consume=True) + expected = JWT_PAYLOAD_SAMPLE.copy() + expected["app_id"] = APP_ID + expected["already_consumed"] = True + assert payload == expected + + expected_url = ( + f"https://firebaseappcheck.googleapis.com/v1beta/projects/{PROJECT_ID}" + ":verifyAppCheckToken" + ) + mock_body.assert_called_once_with( + "post", expected_url, json={"app_check_token": "encoded"} + ) + + @pytest.mark.usefixtures("mock_jwt") + def test_verify_token_with_consume_true_backend_error(self, mocker): + app = firebase_admin.get_app() + app_check_service = app_check._get_app_check_service(app) + + req_exc = requests.exceptions.RequestException("Backend error") + mocker.patch.object(app_check_service._http_client, "body", side_effect=req_exc) + + with pytest.raises(exceptions.UnknownError) as excinfo: + app_check.verify_token("encoded", app=app, consume=True) + assert "Unknown error while making a remote service call" in str(excinfo.value) + + @pytest.mark.usefixtures("mock_jwt") + def test_verify_token_with_consume_true_http_error(self, mocker): + app = firebase_admin.get_app() + app_check_service = app_check._get_app_check_service(app) + + response = requests.Response() + response.status_code = 403 + response._content = ( + b'{"error": {"status": "PERMISSION_DENIED", "message": "Permission denied."}}' + ) + http_exc = requests.exceptions.HTTPError(response=response) + mocker.patch.object(app_check_service._http_client, "body", side_effect=http_exc) + + with pytest.raises(exceptions.PermissionDeniedError) as excinfo: + app_check.verify_token("encoded", app=app, consume=True) + assert "Permission denied." in str(excinfo.value) + + @pytest.mark.usefixtures("mock_jwt") + @pytest.mark.parametrize('malformed_body', ['string_response', [1, 2], 123, None]) + def test_verify_token_with_consume_true_malformed_response_raises_error( + self, mocker, malformed_body + ): + app = firebase_admin.get_app() + app_check_service = app_check._get_app_check_service(app) + mocker.patch.object(app_check_service._http_client, "body", return_value=malformed_body) + + with pytest.raises(exceptions.UnknownError) as excinfo: + app_check.verify_token("encoded", app=app, consume=True) + assert 'Unexpected response from App Check service' in str(excinfo.value) + + @pytest.mark.usefixtures("mock_jwt") + def test_verify_token_with_consume_true_json_decode_error(self, mocker): + app = firebase_admin.get_app() + app_check_service = app_check._get_app_check_service(app) + mocker.patch.object( + app_check_service._http_client, + "body", + side_effect=ValueError("Expecting value: line 1 column 1 (char 0)"), + ) + + with pytest.raises(exceptions.UnknownError) as excinfo: + app_check.verify_token("encoded", app=app, consume=True) + expected_msg = ( + "Unexpected response from App Check service: Expecting value: line 1 column 1 (char 0)" + ) + assert expected_msg in str(excinfo.value) diff --git a/tests/test_auth_providers.py b/tests/test_auth_providers.py index 106e1cae..959ecfde 100644 --- a/tests/test_auth_providers.py +++ b/tests/test_auth_providers.py @@ -45,6 +45,14 @@ }""" INVALID_PROVIDER_IDS = [None, True, False, 1, 0, [], tuple(), {}, ''] +INVALID_OIDC_PROVIDER_IDS = INVALID_PROVIDER_IDS + [ + 'saml.provider', 'oidc.', 'oidc.provider.', 'oidc.provider.\n', 'oidc.provider\n', + 'oidc.provider/sub', 'oidc.provider?param=1', 'oidc.provider#frag', +] +INVALID_SAML_PROVIDER_IDS = INVALID_PROVIDER_IDS + [ + 'oidc.provider', 'saml.', 'saml.provider.', 'saml.provider.\n', 'saml.provider\n', + 'saml.provider/sub', 'saml.provider?param=1', 'saml.provider#frag', +] @pytest.fixture(scope='module', params=[{'emulated': False}, {'emulated': True}]) @@ -105,7 +113,7 @@ class TestOIDCProviderConfig: }, } - @pytest.mark.parametrize('provider_id', INVALID_PROVIDER_IDS + ['saml.provider']) + @pytest.mark.parametrize('provider_id', INVALID_OIDC_PROVIDER_IDS) def test_get_invalid_provider_id(self, user_mgt_app, provider_id): with pytest.raises(ValueError) as excinfo: auth.get_oidc_provider_config(provider_id, app=user_mgt_app) @@ -124,6 +132,9 @@ def test_get(self, user_mgt_app): @pytest.mark.parametrize('invalid_opts', [ {'provider_id': None}, {'provider_id': ''}, {'provider_id': 'saml.provider'}, + {'provider_id': 'oidc.'}, {'provider_id': 'oidc.provider.'}, + {'provider_id': 'oidc.provider/sub'}, {'provider_id': 'oidc.provider?param=1'}, + {'provider_id': 'oidc.provider#frag'}, {'client_id': None}, {'client_id': ''}, {'issuer': None}, {'issuer': ''}, {'issuer': 'not a url'}, {'display_name': True}, @@ -203,6 +214,9 @@ def test_create_empty_values(self, user_mgt_app): @pytest.mark.parametrize('invalid_opts', [ {}, {'provider_id': None}, {'provider_id': ''}, {'provider_id': 'saml.provider'}, + {'provider_id': 'oidc.'}, {'provider_id': 'oidc.provider.'}, + {'provider_id': 'oidc.provider/sub'}, {'provider_id': 'oidc.provider?param=1'}, + {'provider_id': 'oidc.provider#frag'}, {'client_id': ''}, {'issuer': ''}, {'issuer': 'not a url'}, {'display_name': True}, @@ -265,7 +279,7 @@ def test_update_empty_values(self, user_mgt_app): got = json.loads(recorder[0].body.decode()) assert got == {'displayName': None, 'enabled': False, 'responseType': {'idToken': False}} - @pytest.mark.parametrize('provider_id', INVALID_PROVIDER_IDS + ['saml.provider']) + @pytest.mark.parametrize('provider_id', INVALID_OIDC_PROVIDER_IDS) def test_delete_invalid_provider_id(self, user_mgt_app, provider_id): with pytest.raises(ValueError) as excinfo: auth.delete_oidc_provider_config(provider_id, app=user_mgt_app) @@ -303,6 +317,17 @@ def test_list_single_page(self, user_mgt_app): _assert_request(recorder[0], 'GET', f'{USER_MGT_URLS["PREFIX"]}/oauthIdpConfigs?pageSize=100') + def test_list_page_token_encoding(self, user_mgt_app): + recorder = _instrument_provider_mgt(user_mgt_app, 200, LIST_OIDC_PROVIDER_CONFIGS_RESPONSE) + page = auth.list_oidc_provider_configs(page_token='token+1=2&3#4', app=user_mgt_app) + + self._assert_page(page) + assert len(recorder) == 1 + _assert_request( + recorder[0], 'GET', + f'{USER_MGT_URLS["PREFIX"]}/oauthIdpConfigs' + '?pageSize=100&pageToken=token%2B1%3D2%263%234') + def test_list_multiple_pages(self, user_mgt_app): sample_response = json.loads(OIDC_PROVIDER_CONFIG_RESPONSE) configs = _create_list_response(sample_response) @@ -441,7 +466,7 @@ class TestSAMLProviderConfig: } } - @pytest.mark.parametrize('provider_id', INVALID_PROVIDER_IDS + ['oidc.provider']) + @pytest.mark.parametrize('provider_id', INVALID_SAML_PROVIDER_IDS) def test_get_invalid_provider_id(self, user_mgt_app, provider_id): with pytest.raises(ValueError) as excinfo: auth.get_saml_provider_config(provider_id, app=user_mgt_app) @@ -460,6 +485,9 @@ def test_get(self, user_mgt_app): @pytest.mark.parametrize('invalid_opts', [ {'provider_id': None}, {'provider_id': ''}, {'provider_id': 'oidc.provider'}, + {'provider_id': 'saml.'}, {'provider_id': 'saml.provider.'}, + {'provider_id': 'saml.provider/sub'}, {'provider_id': 'saml.provider?param=1'}, + {'provider_id': 'saml.provider#frag'}, {'idp_entity_id': None}, {'idp_entity_id': ''}, {'sso_url': None}, {'sso_url': ''}, {'sso_url': 'not a url'}, {'x509_certificates': None}, {'x509_certificates': []}, {'x509_certificates': 'cert'}, @@ -530,6 +558,9 @@ def test_create_empty_values(self, user_mgt_app): @pytest.mark.parametrize('invalid_opts', [ {}, {'provider_id': None}, {'provider_id': ''}, {'provider_id': 'oidc.provider'}, + {'provider_id': 'saml.'}, {'provider_id': 'saml.provider.'}, + {'provider_id': 'saml.provider/sub'}, {'provider_id': 'saml.provider?param=1'}, + {'provider_id': 'saml.provider#frag'}, {'idp_entity_id': ''}, {'sso_url': ''}, {'sso_url': 'not a url'}, {'x509_certificates': []}, {'x509_certificates': 'cert'}, @@ -592,7 +623,7 @@ def test_update_empty_values(self, user_mgt_app): got = json.loads(recorder[0].body.decode()) assert got == {'displayName': None, 'enabled': False} - @pytest.mark.parametrize('provider_id', INVALID_PROVIDER_IDS + ['oidc.provider']) + @pytest.mark.parametrize('provider_id', INVALID_SAML_PROVIDER_IDS) def test_delete_invalid_provider_id(self, user_mgt_app, provider_id): with pytest.raises(ValueError) as excinfo: auth.delete_saml_provider_config(provider_id, app=user_mgt_app) @@ -642,6 +673,17 @@ def test_list_single_page(self, user_mgt_app): _assert_request( recorder[0], 'GET', f'{USER_MGT_URLS["PREFIX"]}/inboundSamlConfigs?pageSize=100') + def test_list_page_token_encoding(self, user_mgt_app): + recorder = _instrument_provider_mgt(user_mgt_app, 200, LIST_SAML_PROVIDER_CONFIGS_RESPONSE) + page = auth.list_saml_provider_configs(page_token='token+1=2&3#4', app=user_mgt_app) + + self._assert_page(page) + assert len(recorder) == 1 + _assert_request( + recorder[0], 'GET', + f'{USER_MGT_URLS["PREFIX"]}/inboundSamlConfigs' + '?pageSize=100&pageToken=token%2B1%3D2%263%234') + def test_list_multiple_pages(self, user_mgt_app): sample_response = json.loads(SAML_PROVIDER_CONFIG_RESPONSE) configs = _create_list_response(sample_response) diff --git a/tests/test_messaging.py b/tests/test_messaging.py index 749e5311..ae2e4a4d 100644 --- a/tests/test_messaging.py +++ b/tests/test_messaging.py @@ -14,10 +14,12 @@ """Test cases for the firebase_admin.messaging module.""" import datetime +import io from itertools import chain, repeat import json import numbers import httpx +import requests import respx import pytest @@ -1742,10 +1744,25 @@ def test_topic_management_custom_timeout(self, options, timeout): all_options.update(options) firebase_admin.initialize_app(cred, all_options) recorder = self._instrument_service( - 'https://iid.googleapis.com', {'results': [{}, {'error': 'error_reason'}]}) + 'https://fcm.googleapis.com', {}) messaging.subscribe_to_topic(['1'], 'a') self._check_timeout(recorder, timeout) + @pytest.mark.parametrize('options, timeout', [ + ({'httpTimeout': 4}, 4), + ({'httpTimeout': None}, None), + ({}, _http_client.DEFAULT_TIMEOUT_SECONDS), + ]) + def test_topic_management_legacy_custom_timeout(self, options, timeout): + cred = testutils.MockCredential() + all_options = {'projectId': 'explicit-project-id'} + all_options.update(options) + firebase_admin.initialize_app(cred, all_options) + recorder = self._instrument_service( + 'https://iid.googleapis.com', {'results': [{}, {'error': 'error_reason'}]}) + messaging.subscribe_to_topic_legacy(['1'], 'a') + self._check_timeout(recorder, timeout) + class TestSend: @@ -2448,7 +2465,7 @@ def test_send_each_for_multicast_fcm_error_code(self, status): check_exception(exception, 'test error', status) -class TestTopicManagement: +class TestTopicManagementLegacy: _DEFAULT_RESPONSE = json.dumps({'results': [{}, {'error': 'error_reason'}]}) _DEFAULT_ERROR_RESPONSE = json.dumps({'error': 'error_reason'}) @@ -2502,77 +2519,79 @@ def test_invalid_tokens(self, tokens): expected = 'Tokens must be non-empty strings.' with pytest.raises(ValueError) as excinfo: - messaging.subscribe_to_topic(tokens, 'test-topic') + messaging.subscribe_to_topic_legacy(tokens, 'test-topic') assert str(excinfo.value) == expected with pytest.raises(ValueError) as excinfo: - messaging.unsubscribe_from_topic(tokens, 'test-topic') + messaging.unsubscribe_from_topic_legacy(tokens, 'test-topic') assert str(excinfo.value) == expected @pytest.mark.parametrize('topic', NON_STRING_ARGS + [None, '']) def test_invalid_topic(self, topic): expected = 'Topic must be a non-empty string.' with pytest.raises(ValueError) as excinfo: - messaging.subscribe_to_topic('test-token', topic) + messaging.subscribe_to_topic_legacy('test-token', topic) assert str(excinfo.value) == expected with pytest.raises(ValueError) as excinfo: - messaging.unsubscribe_from_topic('test-tokens', topic) + messaging.unsubscribe_from_topic_legacy('test-tokens', topic) assert str(excinfo.value) == expected @pytest.mark.parametrize('args', _VALID_ARGS) - def test_subscribe_to_topic(self, args): + def test_subscribe_to_topic_legacy(self, args): _, recorder = self._instrument_iid_service() - resp = messaging.subscribe_to_topic(args[0], args[1]) + with pytest.deprecated_call(): + resp = messaging.subscribe_to_topic_legacy(args[0], args[1]) self._check_response(resp) assert len(recorder) == 1 self._assert_request(recorder[0], 'POST', self._get_url('iid/v1:batchAdd')) assert json.loads(recorder[0].body.decode()) == args[2] @pytest.mark.parametrize('status, exc_type', HTTP_ERROR_CODES.items()) - def test_subscribe_to_topic_error(self, status, exc_type): + def test_subscribe_to_topic_legacy_error(self, status, exc_type): _, recorder = self._instrument_iid_service( status=status, payload=self._DEFAULT_ERROR_RESPONSE) with pytest.raises(exc_type) as excinfo: - messaging.subscribe_to_topic('foo', 'test-topic') + messaging.subscribe_to_topic_legacy('foo', 'test-topic') assert str(excinfo.value) == 'Error while calling the IID service: error_reason' assert len(recorder) == 1 self._assert_request(recorder[0], 'POST', self._get_url('iid/v1:batchAdd')) @pytest.mark.parametrize('status, exc_type', HTTP_ERROR_CODES.items()) - def test_subscribe_to_topic_non_json_error(self, status, exc_type): + def test_subscribe_to_topic_legacy_non_json_error(self, status, exc_type): _, recorder = self._instrument_iid_service(status=status, payload='not json') with pytest.raises(exc_type) as excinfo: - messaging.subscribe_to_topic('foo', 'test-topic') + messaging.subscribe_to_topic_legacy('foo', 'test-topic') reason = f'Unexpected HTTP response with status: {status}; body: not json' assert str(excinfo.value) == reason assert len(recorder) == 1 self._assert_request(recorder[0], 'POST', self._get_url('iid/v1:batchAdd')) @pytest.mark.parametrize('args', _VALID_ARGS) - def test_unsubscribe_from_topic(self, args): + def test_unsubscribe_from_topic_legacy(self, args): _, recorder = self._instrument_iid_service() - resp = messaging.unsubscribe_from_topic(args[0], args[1]) + with pytest.deprecated_call(): + resp = messaging.unsubscribe_from_topic_legacy(args[0], args[1]) self._check_response(resp) assert len(recorder) == 1 self._assert_request(recorder[0], 'POST', self._get_url('iid/v1:batchRemove')) assert json.loads(recorder[0].body.decode()) == args[2] @pytest.mark.parametrize('status, exc_type', HTTP_ERROR_CODES.items()) - def test_unsubscribe_from_topic_error(self, status, exc_type): + def test_unsubscribe_from_topic_legacy_error(self, status, exc_type): _, recorder = self._instrument_iid_service( status=status, payload=self._DEFAULT_ERROR_RESPONSE) with pytest.raises(exc_type) as excinfo: - messaging.unsubscribe_from_topic('foo', 'test-topic') + messaging.unsubscribe_from_topic_legacy('foo', 'test-topic') assert str(excinfo.value) == 'Error while calling the IID service: error_reason' assert len(recorder) == 1 self._assert_request(recorder[0], 'POST', self._get_url('iid/v1:batchRemove')) @pytest.mark.parametrize('status, exc_type', HTTP_ERROR_CODES.items()) - def test_unsubscribe_from_topic_non_json_error(self, status, exc_type): + def test_unsubscribe_from_topic_legacy_non_json_error(self, status, exc_type): _, recorder = self._instrument_iid_service(status=status, payload='not json') with pytest.raises(exc_type) as excinfo: - messaging.unsubscribe_from_topic('foo', 'test-topic') + messaging.unsubscribe_from_topic_legacy('foo', 'test-topic') reason = f'Unexpected HTTP response with status: {status}; body: not json' assert str(excinfo.value) == reason assert len(recorder) == 1 @@ -2584,3 +2603,530 @@ def _check_response(self, resp): assert len(resp.errors) == 1 assert resp.errors[0].index == 1 assert resp.errors[0].reason == 'error_reason' + + +class TestTopicManagement: + + _CLIENT_VERSION = f'fire-admin-python/{firebase_admin.__version__}' + + @classmethod + def setup_class(cls): + cred = testutils.MockCredential() + firebase_admin.initialize_app(cred, {'projectId': 'explicit-project-id'}) + + @classmethod + def teardown_class(cls): + testutils.cleanup_apps() + + def _instrument_messaging_service(self, app=None, status=200, payload='{}'): + if not app: + app = firebase_admin.get_app() + fcm_service = messaging._get_messaging_service(app) + recorder = [] + fcm_service._client.session.mount( + 'https://fcm.googleapis.com', + testutils.MockAdapter(payload, status, recorder)) + return fcm_service, recorder + + def _assert_request(self, request, expected_method, expected_url, expected_body=None): + assert request.method == expected_method + assert request.url == expected_url + assert request.headers['X-GOOG-API-FORMAT-VERSION'] == '2' + assert request.headers['X-FIREBASE-CLIENT'] == self._CLIENT_VERSION + expected_metrics_header = _utils.get_metrics_header() + ' mock-cred-metric-tag' + assert request.headers['x-goog-api-client'] == expected_metrics_header + if expected_body is None: + assert request.body is None + else: + assert json.loads(request.body.decode()) == expected_body + + @pytest.mark.parametrize('tokens', [None, '', [], {}, tuple()]) + def test_invalid_tokens(self, tokens): + expected = 'Tokens must be a string or a non-empty list of strings.' + if isinstance(tokens, str): + expected = 'Tokens must be non-empty strings.' + + with pytest.raises(ValueError) as excinfo: + messaging.subscribe_to_topic(tokens, 'test-topic') + assert str(excinfo.value) == expected + + with pytest.raises(ValueError) as excinfo: + messaging.unsubscribe_from_topic(tokens, 'test-topic') + assert str(excinfo.value) == expected + + @pytest.mark.parametrize('tokens', [ + ['foo', 'bar', ''], + ['foo', 123, 'bar'], + ]) + def test_invalid_tokens_in_list(self, tokens): + with pytest.raises(ValueError) as excinfo: + messaging.subscribe_to_topic(tokens, 'test-topic') + assert str(excinfo.value) == 'Tokens must be non-empty strings.' + + with pytest.raises(ValueError) as excinfo: + messaging.unsubscribe_from_topic(tokens, 'test-topic') + assert str(excinfo.value) == 'Tokens must be non-empty strings.' + + def test_tokens_over_1000(self): + tokens = [f'token{i}' for i in range(1001)] + with pytest.raises(ValueError) as excinfo: + messaging.subscribe_to_topic(tokens, 'test-topic') + assert str(excinfo.value) == 'tokens must not contain more than 1000 elements.' + + with pytest.raises(ValueError) as excinfo: + messaging.unsubscribe_from_topic(tokens, 'test-topic') + assert str(excinfo.value) == 'tokens must not contain more than 1000 elements.' + + @pytest.mark.parametrize('topic', NON_STRING_ARGS + [None, '']) + def test_invalid_topic(self, topic): + expected = 'Topic must be a non-empty string.' + with pytest.raises(ValueError) as excinfo: + messaging.subscribe_to_topic('test-token', topic) + assert str(excinfo.value) == expected + + with pytest.raises(ValueError) as excinfo: + messaging.unsubscribe_from_topic('test-token', topic) + assert str(excinfo.value) == expected + + @pytest.mark.parametrize('topic', [ + '/topics/', '/foo/bar', 'foo bar', 'f*o*o', '/topics/f+o+o', '$foo', '/topics/foo&', + '/topics/foo\n', 'foo\n', + ]) + def test_malformed_topic(self, topic): + with pytest.raises(ValueError) as excinfo: + messaging.subscribe_to_topic('test-token', topic) + assert str(excinfo.value) == 'Malformed topic name.' + + with pytest.raises(ValueError) as excinfo: + messaging.unsubscribe_from_topic('test-token', topic) + assert str(excinfo.value) == 'Malformed topic name.' + + def test_subscribe_to_topic_single(self): + _, recorder = self._instrument_messaging_service() + resp = messaging.subscribe_to_topic('token1', 'test-topic') + assert resp.success_count == 1 + assert resp.failure_count == 0 + assert resp.errors == [] + assert len(recorder) == 1 + expected_url = ( + 'https://fcm.googleapis.com/v1/projects/explicit-project-id/registrations/' + 'token1/topicSubscriptions?topic_name=test-topic' + ) + self._assert_request(recorder[0], 'POST', expected_url, {}) + + def test_subscribe_to_topic_prefixed(self): + _, recorder = self._instrument_messaging_service() + resp = messaging.subscribe_to_topic('token1', '/topics/test-topic') + assert resp.success_count == 1 + assert resp.failure_count == 0 + assert resp.errors == [] + assert len(recorder) == 1 + expected_url = ( + 'https://fcm.googleapis.com/v1/projects/explicit-project-id/registrations/' + 'token1/topicSubscriptions?topic_name=test-topic' + ) + self._assert_request(recorder[0], 'POST', expected_url, {}) + + def test_unsubscribe_from_topic_single(self): + _, recorder = self._instrument_messaging_service() + resp = messaging.unsubscribe_from_topic('token1', 'test-topic') + assert resp.success_count == 1 + assert resp.failure_count == 0 + assert resp.errors == [] + assert len(recorder) == 1 + expected_url = ( + 'https://fcm.googleapis.com/v1/projects/explicit-project-id/registrations/' + 'token1/topicSubscriptions/test-topic?allow_missing=true' + ) + self._assert_request(recorder[0], 'DELETE', expected_url, None) + + def test_unsubscribe_from_topic_prefixed(self): + _, recorder = self._instrument_messaging_service() + resp = messaging.unsubscribe_from_topic('token1', '/topics/test-topic') + assert resp.success_count == 1 + assert resp.failure_count == 0 + assert resp.errors == [] + assert len(recorder) == 1 + expected_url = ( + 'https://fcm.googleapis.com/v1/projects/explicit-project-id/registrations/' + 'token1/topicSubscriptions/test-topic?allow_missing=true' + ) + self._assert_request(recorder[0], 'DELETE', expected_url, None) + + def test_subscribe_to_topic_already_exists_409(self): + payload = json.dumps({'error': {'status': 'ALREADY_EXISTS', 'message': 'Already exists'}}) + _, recorder = self._instrument_messaging_service(status=409, payload=payload) + resp = messaging.subscribe_to_topic('token1', 'test-topic') + assert resp.success_count == 1 + assert resp.failure_count == 0 + assert resp.errors == [] + assert len(recorder) == 1 + + def test_unsubscribe_from_topic_not_found_404(self): + payload = json.dumps({'error': {'status': 'NOT_FOUND', 'message': 'Not found'}}) + _, recorder = self._instrument_messaging_service(status=404, payload=payload) + resp = messaging.unsubscribe_from_topic('token1', 'test-topic') + assert resp.success_count == 0 + assert resp.failure_count == 1 + assert len(resp.errors) == 1 + assert resp.errors[0].index == 0 + assert resp.errors[0].reason == 'NOT_FOUND' + assert len(recorder) == 1 + + def test_topic_management_multiple_tokens(self): + fcm_service = messaging._get_messaging_service(firebase_admin.get_app()) + recorder = [] + + class MultiTokenMockAdapter(requests.adapters.HTTPAdapter): + def send(self, request, **kwargs): # pylint: disable=arguments-differ,unused-argument + recorder.append(request) + resp = requests.models.Response() + resp.url = request.url + if 'token2' in request.url: + resp.status_code = 404 + content = json.dumps({'error': {'status': 'NOT_FOUND'}}).encode() + else: + resp.status_code = 200 + content = b'{}' + resp.raw = io.BytesIO(content) + return resp + + fcm_service._client.session.mount( + 'https://fcm.googleapis.com', + MultiTokenMockAdapter()) + + resp = messaging.subscribe_to_topic(['token1', 'token2', 'token3'], 'test-topic') + assert resp.success_count == 2 + assert resp.failure_count == 1 + assert len(resp.errors) == 1 + assert resp.errors[0].index == 1 + assert resp.errors[0].reason == 'NOT_FOUND' + assert len(recorder) == 3 + + def test_topic_management_fcm_error_details(self): + payload = json.dumps({ + 'error': { + 'status': 'NOT_FOUND', + 'details': [ + { + '@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError', + 'errorCode': 'UNREGISTERED', + }, + ], + } + }) + _, recorder = self._instrument_messaging_service(status=404, payload=payload) + resp = messaging.subscribe_to_topic('token1', 'test-topic') + assert resp.success_count == 0 + assert resp.failure_count == 1 + assert len(resp.errors) == 1 + assert resp.errors[0].index == 0 + assert resp.errors[0].reason == 'UNREGISTERED' + assert len(recorder) == 1 + + def test_topic_management_500_error(self): + _, recorder = self._instrument_messaging_service(status=500, payload='{"error": null}') + resp = messaging.subscribe_to_topic('token1', 'test-topic') + assert resp.success_count == 0 + assert resp.failure_count == 1 + assert len(resp.errors) == 1 + assert resp.errors[0].index == 0 + assert resp.errors[0].reason == 'INTERNAL' + assert len(recorder) == 1 + + def test_topic_management_non_json_error(self): + _, recorder = self._instrument_messaging_service(status=400, payload='not json') + resp = messaging.subscribe_to_topic('token1', 'test-topic') + assert resp.success_count == 0 + assert resp.failure_count == 1 + assert len(resp.errors) == 1 + assert resp.errors[0].index == 0 + assert resp.errors[0].reason == 'INVALID_ARGUMENT' + assert len(recorder) == 1 + + @pytest.mark.parametrize('status_code, expected_reason', [ + (401, 'UNAUTHENTICATED'), + (403, 'PERMISSION_DENIED'), + (408, 'DEADLINE_EXCEEDED'), + (429, 'RESOURCE_EXHAUSTED'), + (503, 'UNAVAILABLE'), + (504, 'DEADLINE_EXCEEDED'), + ]) + def test_topic_management_status_code_mapping(self, status_code, expected_reason): + _, recorder = self._instrument_messaging_service( + status=status_code, payload=json.dumps({'error': {'message': 'Some message'}})) + resp = messaging.subscribe_to_topic('token1', 'test-topic') + assert resp.success_count == 0 + assert resp.failure_count == 1 + assert len(resp.errors) == 1 + assert resp.errors[0].index == 0 + assert resp.errors[0].reason == expected_reason + assert len(recorder) == (3 if status_code == 401 else 1) + + +class TestTopicManagementAsync: + + @classmethod + def setup_class(cls): + cred = testutils.MockCredential() + firebase_admin.initialize_app(cred, {'projectId': 'explicit-project-id'}) + + @classmethod + def teardown_class(cls): + testutils.cleanup_apps() + + @pytest.mark.asyncio + @respx.mock + async def test_subscribe_to_topic_async_single(self): + url = ( + 'https://fcm.googleapis.com/v1/projects/explicit-project-id/registrations/' + 'token1/topicSubscriptions?topic_name=test-topic' + ) + route = respx.post(url).mock(return_value=respx.MockResponse(200, json={})) + resp = await messaging.subscribe_to_topic_async('token1', 'test-topic') + assert route.call_count == 1 + assert resp.success_count == 1 + assert resp.failure_count == 0 + assert resp.errors == [] + + @pytest.mark.asyncio + @respx.mock + async def test_subscribe_to_topic_async_already_exists(self): + url = ( + 'https://fcm.googleapis.com/v1/projects/explicit-project-id/registrations/' + 'token1/topicSubscriptions?topic_name=test-topic' + ) + payload = {'error': {'status': 'ALREADY_EXISTS', 'message': 'Already exists'}} + route = respx.post(url).mock(return_value=respx.MockResponse(409, json=payload)) + resp = await messaging.subscribe_to_topic_async('token1', 'test-topic') + assert route.call_count == 1 + assert resp.success_count == 1 + assert resp.failure_count == 0 + assert resp.errors == [] + + @pytest.mark.asyncio + @respx.mock + async def test_unsubscribe_from_topic_async_single(self): + url = ( + 'https://fcm.googleapis.com/v1/projects/explicit-project-id/registrations/' + 'token1/topicSubscriptions/test-topic?allow_missing=true' + ) + route = respx.delete(url).mock(return_value=respx.MockResponse(200, json={})) + resp = await messaging.unsubscribe_from_topic_async('token1', 'test-topic') + assert route.call_count == 1 + assert resp.success_count == 1 + assert resp.failure_count == 0 + assert resp.errors == [] + + @pytest.mark.asyncio + @respx.mock + async def test_unsubscribe_from_topic_async_not_found(self): + url = ( + 'https://fcm.googleapis.com/v1/projects/explicit-project-id/registrations/' + 'token1/topicSubscriptions/test-topic?allow_missing=true' + ) + payload = {'error': {'status': 'NOT_FOUND', 'message': 'Not found'}} + route = respx.delete(url).mock(return_value=respx.MockResponse(404, json=payload)) + resp = await messaging.unsubscribe_from_topic_async('token1', 'test-topic') + assert route.call_count == 1 + assert resp.success_count == 0 + assert resp.failure_count == 1 + assert len(resp.errors) == 1 + assert resp.errors[0].index == 0 + assert resp.errors[0].reason == 'NOT_FOUND' + + @pytest.mark.asyncio + @respx.mock + async def test_subscribe_to_topic_async_multiple(self): + base = 'https://fcm.googleapis.com/v1/projects/explicit-project-id/registrations' + url1 = f'{base}/token1/topicSubscriptions?topic_name=test-topic' + url2 = f'{base}/token2/topicSubscriptions?topic_name=test-topic' + url3 = f'{base}/token3/topicSubscriptions?topic_name=test-topic' + route1 = respx.post(url1).mock(return_value=respx.MockResponse(200, json={})) + route2 = respx.post(url2).mock( + return_value=respx.MockResponse(404, json={'error': {'status': 'NOT_FOUND'}})) + route3 = respx.post(url3).mock(return_value=respx.MockResponse(200, json={})) + + resp = await messaging.subscribe_to_topic_async( + ['token1', 'token2', 'token3'], 'test-topic') + assert route1.call_count == 1 + assert route2.call_count == 1 + assert route3.call_count == 1 + assert resp.success_count == 2 + assert resp.failure_count == 1 + assert len(resp.errors) == 1 + assert resp.errors[0].index == 1 + assert resp.errors[0].reason == 'NOT_FOUND' + + @pytest.mark.asyncio + @respx.mock + async def test_subscribe_to_topic_async_prefixed(self): + url = ( + 'https://fcm.googleapis.com/v1/projects/explicit-project-id/registrations/' + 'token1/topicSubscriptions?topic_name=test-topic' + ) + route = respx.post(url).mock(return_value=respx.MockResponse(200, json={})) + resp = await messaging.subscribe_to_topic_async('token1', '/topics/test-topic') + assert route.call_count == 1 + assert resp.success_count == 1 + assert resp.failure_count == 0 + assert resp.errors == [] + + @pytest.mark.asyncio + @respx.mock + async def test_unsubscribe_from_topic_async_prefixed(self): + url = ( + 'https://fcm.googleapis.com/v1/projects/explicit-project-id/registrations/' + 'token1/topicSubscriptions/test-topic?allow_missing=true' + ) + route = respx.delete(url).mock(return_value=respx.MockResponse(200, json={})) + resp = await messaging.unsubscribe_from_topic_async('token1', '/topics/test-topic') + assert route.call_count == 1 + assert resp.success_count == 1 + assert resp.failure_count == 0 + assert resp.errors == [] + + @pytest.mark.asyncio + @respx.mock + async def test_unsubscribe_from_topic_async_multiple(self): + base = 'https://fcm.googleapis.com/v1/projects/explicit-project-id/registrations' + url1 = f'{base}/token1/topicSubscriptions/test-topic?allow_missing=true' + url2 = f'{base}/token2/topicSubscriptions/test-topic?allow_missing=true' + url3 = f'{base}/token3/topicSubscriptions/test-topic?allow_missing=true' + route1 = respx.delete(url1).mock(return_value=respx.MockResponse(200, json={})) + route2 = respx.delete(url2).mock( + return_value=respx.MockResponse(404, json={'error': {'status': 'NOT_FOUND'}})) + route3 = respx.delete(url3).mock(return_value=respx.MockResponse(200, json={})) + + resp = await messaging.unsubscribe_from_topic_async( + ['token1', 'token2', 'token3'], 'test-topic') + assert route1.call_count == 1 + assert route2.call_count == 1 + assert route3.call_count == 1 + assert resp.success_count == 2 + assert resp.failure_count == 1 + assert len(resp.errors) == 1 + assert resp.errors[0].index == 1 + assert resp.errors[0].reason == 'NOT_FOUND' + + @pytest.mark.asyncio + @pytest.mark.parametrize('tokens', [None, '', [], {}, tuple()]) + async def test_invalid_tokens(self, tokens): + expected = 'Tokens must be a string or a non-empty list of strings.' + if isinstance(tokens, str): + expected = 'Tokens must be non-empty strings.' + + with pytest.raises(ValueError) as excinfo: + await messaging.subscribe_to_topic_async(tokens, 'test-topic') + assert str(excinfo.value) == expected + + with pytest.raises(ValueError) as excinfo: + await messaging.unsubscribe_from_topic_async(tokens, 'test-topic') + assert str(excinfo.value) == expected + + @pytest.mark.asyncio + @pytest.mark.parametrize('tokens', [ + ['foo', 'bar', ''], + ['foo', 123, 'bar'], + ]) + async def test_invalid_tokens_in_list(self, tokens): + with pytest.raises(ValueError) as excinfo: + await messaging.subscribe_to_topic_async(tokens, 'test-topic') + assert str(excinfo.value) == 'Tokens must be non-empty strings.' + + with pytest.raises(ValueError) as excinfo: + await messaging.unsubscribe_from_topic_async(tokens, 'test-topic') + assert str(excinfo.value) == 'Tokens must be non-empty strings.' + + @pytest.mark.asyncio + async def test_tokens_over_1000(self): + tokens = [f'token{i}' for i in range(1001)] + with pytest.raises(ValueError) as excinfo: + await messaging.subscribe_to_topic_async(tokens, 'test-topic') + assert str(excinfo.value) == 'tokens must not contain more than 1000 elements.' + + with pytest.raises(ValueError) as excinfo: + await messaging.unsubscribe_from_topic_async(tokens, 'test-topic') + assert str(excinfo.value) == 'tokens must not contain more than 1000 elements.' + + @pytest.mark.asyncio + @pytest.mark.parametrize('topic', NON_STRING_ARGS + [None, '']) + async def test_invalid_topic(self, topic): + expected = 'Topic must be a non-empty string.' + with pytest.raises(ValueError) as excinfo: + await messaging.subscribe_to_topic_async('test-token', topic) + assert str(excinfo.value) == expected + + with pytest.raises(ValueError) as excinfo: + await messaging.unsubscribe_from_topic_async('test-token', topic) + assert str(excinfo.value) == expected + + @pytest.mark.asyncio + @pytest.mark.parametrize('topic', [ + '/topics/', '/foo/bar', 'foo bar', 'f*o*o', '/topics/f+o+o', '$foo', '/topics/foo&', + '/topics/foo\n', 'foo\n', + ]) + async def test_malformed_topic(self, topic): + with pytest.raises(ValueError) as excinfo: + await messaging.subscribe_to_topic_async('test-token', topic) + assert str(excinfo.value) == 'Malformed topic name.' + + with pytest.raises(ValueError) as excinfo: + await messaging.unsubscribe_from_topic_async('test-token', topic) + assert str(excinfo.value) == 'Malformed topic name.' + + @pytest.mark.asyncio + @respx.mock + async def test_topic_management_async_fcm_error_details(self): + url = ( + 'https://fcm.googleapis.com/v1/projects/explicit-project-id/registrations/' + 'token1/topicSubscriptions?topic_name=test-topic' + ) + payload = { + 'error': { + 'status': 'NOT_FOUND', + 'details': [ + { + '@type': 'type.googleapis.com/google.firebase.fcm.v1.FcmError', + 'errorCode': 'UNREGISTERED', + }, + ], + } + } + respx.post(url).mock(return_value=respx.MockResponse(404, json=payload)) + resp = await messaging.subscribe_to_topic_async('token1', 'test-topic') + assert resp.success_count == 0 + assert resp.failure_count == 1 + assert len(resp.errors) == 1 + assert resp.errors[0].index == 0 + assert resp.errors[0].reason == 'UNREGISTERED' + + @pytest.mark.asyncio + @respx.mock + async def test_topic_management_async_500_error(self): + url = ( + 'https://fcm.googleapis.com/v1/projects/explicit-project-id/registrations/' + 'token1/topicSubscriptions?topic_name=test-topic' + ) + respx.post(url).mock(return_value=respx.MockResponse(500, json={'error': None})) + resp = await messaging.subscribe_to_topic_async('token1', 'test-topic') + assert resp.success_count == 0 + assert resp.failure_count == 1 + assert len(resp.errors) == 1 + assert resp.errors[0].index == 0 + assert resp.errors[0].reason == 'INTERNAL' + + @pytest.mark.asyncio + @respx.mock + async def test_topic_management_async_non_json_error(self): + url = ( + 'https://fcm.googleapis.com/v1/projects/explicit-project-id/registrations/' + 'token1/topicSubscriptions?topic_name=test-topic' + ) + respx.post(url).mock(return_value=respx.MockResponse(400, text='not json')) + resp = await messaging.subscribe_to_topic_async('token1', 'test-topic') + assert resp.success_count == 0 + assert resp.failure_count == 1 + assert len(resp.errors) == 1 + assert resp.errors[0].index == 0 + assert resp.errors[0].reason == 'INVALID_ARGUMENT' diff --git a/tests/test_ml.py b/tests/test_ml.py index bcc93fd0..af97b174 100644 --- a/tests/test_ml.py +++ b/tests/test_ml.py @@ -15,6 +15,7 @@ """Test cases for the firebase_admin.ml module.""" import json +import warnings import pytest @@ -1086,3 +1087,89 @@ def test_list_models_no_models(self): assert len(page.models) == 0 models = list(page.iterate_all()) assert len(models) == 0 + + +class TestMLDeprecation: + """Tests for verifying deprecation warnings in the ML module.""" + + DEPRECATION_MSG = ( + r'Firebase ML is deprecated and will be shut down on June 15, 2027\. ' + r'To host custom models, you must migrate to another solution\. ' + r'You can use Cloud Storage for Firebase as an alternative for hosting custom models\. ' + r'For more info, see https://firebase\.google\.com/docs/ml/migrate-to-cloud-storage' + ) + + @classmethod + def setup_class(cls): + cred = testutils.MockCredential() + firebase_admin.initialize_app(cred, {'projectId': PROJECT_ID}) + ml._MLService.POLL_BASE_WAIT_TIME_SECONDS = 0.1 + ml.TFLiteGCSModelSource._STORAGE_CLIENT = _TestStorageClient() + + @classmethod + def teardown_class(cls): + testutils.cleanup_apps() + + def test_model_init_deprecation_warning(self): + with pytest.warns(DeprecationWarning, match=self.DEPRECATION_MSG): + ml.Model() + + def test_tflite_format_init_deprecation_warning(self): + with pytest.warns(DeprecationWarning, match=self.DEPRECATION_MSG): + ml.TFLiteFormat() + + def test_tflite_gcs_model_source_init_deprecation_warning(self): + with pytest.warns(DeprecationWarning, match=self.DEPRECATION_MSG): + ml.TFLiteGCSModelSource(GCS_TFLITE_URI) + + def test_get_model_deprecation_warning(self): + instrument_ml_service(status=200, payload=DEFAULT_GET_RESPONSE) + with pytest.warns(DeprecationWarning, match=self.DEPRECATION_MSG): + ml.get_model(MODEL_ID_1) + + def test_list_models_deprecation_warning(self): + instrument_ml_service(status=200, payload=ONE_PAGE_LIST_RESPONSE) + with pytest.warns(DeprecationWarning, match=self.DEPRECATION_MSG): + ml.list_models() + + def test_delete_model_deprecation_warning(self): + instrument_ml_service(status=200, payload=EMPTY_RESPONSE) + with pytest.warns(DeprecationWarning, match=self.DEPRECATION_MSG): + ml.delete_model(MODEL_ID_1) + + def test_create_model_deprecation_warning(self): + instrument_ml_service(status=200, payload=OPERATION_DONE_RESPONSE) + with pytest.warns(DeprecationWarning, match=self.DEPRECATION_MSG): + ml.create_model(ml.Model(display_name=DISPLAY_NAME_1)) + + def test_update_model_deprecation_warning(self): + instrument_ml_service(status=200, payload=OPERATION_DONE_RESPONSE) + with pytest.warns(DeprecationWarning, match=self.DEPRECATION_MSG): + ml.update_model(ml.Model(display_name=DISPLAY_NAME_1)) + + def test_publish_model_deprecation_warning(self): + instrument_ml_service(status=200, payload=OPERATION_DONE_RESPONSE) + with pytest.warns(DeprecationWarning, match=self.DEPRECATION_MSG): + ml.publish_model(MODEL_ID_1) + + def test_unpublish_model_deprecation_warning(self): + instrument_ml_service(status=200, payload=OPERATION_DONE_RESPONSE) + with pytest.warns(DeprecationWarning, match=self.DEPRECATION_MSG): + ml.unpublish_model(MODEL_ID_1) + + def test_single_warning_on_get_model(self): + instrument_ml_service(status=200, payload=DEFAULT_GET_RESPONSE) + with warnings.catch_warnings(record=True) as captured_warnings: + warnings.simplefilter('always') + ml.get_model(MODEL_ID_1) + assert len(captured_warnings) == 1 + assert issubclass(captured_warnings[0].category, DeprecationWarning) + + def test_single_warning_on_list_models(self): + instrument_ml_service(status=200, payload=ONE_PAGE_LIST_RESPONSE) + with warnings.catch_warnings(record=True) as captured_warnings: + warnings.simplefilter('always') + page = ml.list_models() + _ = page.models + assert len(captured_warnings) == 1 + assert issubclass(captured_warnings[0].category, DeprecationWarning) diff --git a/tests/test_project_management.py b/tests/test_project_management.py index 89e48c2e..0536c291 100644 --- a/tests/test_project_management.py +++ b/tests/test_project_management.py @@ -841,6 +841,32 @@ def test_list_android_apps_multiple_pages(self): self._assert_request_is_correct(recorder[0], 'GET', TestListAndroidApps._LISTING_URL) self._assert_request_is_correct(recorder[1], 'GET', TestListAndroidApps._LISTING_PAGE_2_URL) + def test_list_android_apps_page_token_encoding(self): + page_1_response = json.dumps({ + 'apps': [{ + 'name': 'projects/test-project-id/androidApps/1:12345678:android:deadbeef', + 'appId': '1:12345678:android:deadbeef', + 'displayName': 'My Android App', + 'projectId': 'test-project-id', + 'packageName': 'com.hello.world.android', + }], + 'nextPageToken': 'token+1=2&3#4', + }) + recorder = self._instrument_service( + statuses=[200, 200], + responses=[page_1_response, LIST_ANDROID_APPS_PAGE_2_RESPONSE]) + + android_apps = project_management.list_android_apps() + + assert len(android_apps) == 2 + assert len(recorder) == 2 + self._assert_request_is_correct(recorder[0], 'GET', TestListAndroidApps._LISTING_URL) + expected_page_2_url = ( + 'https://firebase.googleapis.com/v1beta1/projects/test-project-id/' + 'androidApps?pageToken=token%2B1%3D2%263%234&pageSize=100' + ) + self._assert_request_is_correct(recorder[1], 'GET', expected_page_2_url) + def test_list_android_apps_multiple_pages_rpc_error(self): recorder = self._instrument_service( statuses=[200, 503], diff --git a/tests/test_tenant_mgt.py b/tests/test_tenant_mgt.py index 900faa37..491924a3 100644 --- a/tests/test_tenant_mgt.py +++ b/tests/test_tenant_mgt.py @@ -107,7 +107,11 @@ LIST_OIDC_PROVIDER_CONFIGS_RESPONSE = testutils.resource('list_oidc_provider_configs.json') LIST_SAML_PROVIDER_CONFIGS_RESPONSE = testutils.resource('list_saml_provider_configs.json') -INVALID_TENANT_IDS = [None, '', 0, 1, True, False, [], tuple(), {}] +INVALID_TENANT_IDS = [ + None, '', '.', '..', '..\n', 'tenant.', 'tenant.\n', 'tenant\n', + 'tenant/sub', 'tenant?param=1', 'tenant#frag', + 0, 1, True, False, [], tuple(), {}, +] INVALID_BOOLEANS = ['', 1, 0, [], tuple(), {}] USER_MGT_URL_PREFIX = 'https://identitytoolkit.googleapis.com/v1/projects/mock-project-id' @@ -304,7 +308,7 @@ class TestUpdateTenant: def test_invalid_tenant_id(self, tenant_id, tenant_mgt_app): with pytest.raises(ValueError) as excinfo: tenant_mgt.update_tenant(tenant_id, display_name='My Tenant', app=tenant_mgt_app) - assert str(excinfo.value).startswith('Tenant ID must be a non-empty string') + assert str(excinfo.value).startswith('Invalid tenant ID') @pytest.mark.parametrize('display_name', [True, False, 1, 0, [], tuple(), {}]) def test_invalid_display_name_type(self, display_name, tenant_mgt_app):