firebase-admin 7.6.0__tar.gz → 7.7.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/PKG-INFO +1 -1
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/__about__.py +1 -1
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/app_check.py +1 -1
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/messaging.py +339 -8
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin.egg-info/PKG-INFO +1 -1
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_app_check.py +4 -2
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_messaging.py +564 -18
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/LICENSE +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/README.md +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/__init__.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/_auth_client.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/_auth_providers.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/_auth_utils.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/_http_client.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/_messaging_encoder.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/_messaging_utils.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/_retry.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/_rfc3339.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/_sseclient.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/_token_gen.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/_user_identifier.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/_user_import.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/_user_mgt.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/_utils.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/auth.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/credentials.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/db.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/exceptions.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/firestore.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/firestore_async.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/functions.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/instance_id.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/ml.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/phone_number_verification.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/project_management.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/remote_config.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/storage.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin/tenant_mgt.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin.egg-info/SOURCES.txt +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin.egg-info/dependency_links.txt +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin.egg-info/requires.txt +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/firebase_admin.egg-info/top_level.txt +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/setup.cfg +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/setup.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_app.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_auth_providers.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_credentials.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_db.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_exceptions.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_firestore.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_firestore_async.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_functions.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_http_client.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_instance_id.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_ml.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_phone_number_verification.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_project_management.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_remote_config.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_retry.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_rfc3339.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_sseclient.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_storage.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_tenant_mgt.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_token_gen.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/test_user_mgt.py +0 -0
- {firebase_admin-7.6.0 → firebase_admin-7.7.0}/tests/testutils.py +0 -0
|
@@ -54,7 +54,7 @@ class _AppCheckService:
|
|
|
54
54
|
_APP_CHECK_ISSUER = 'https://firebaseappcheck.googleapis.com/'
|
|
55
55
|
_JWKS_URL = 'https://firebaseappcheck.googleapis.com/v1/jwks'
|
|
56
56
|
_VERIFY_URL_FORMAT = (
|
|
57
|
-
'https://firebaseappcheck.googleapis.com/
|
|
57
|
+
'https://firebaseappcheck.googleapis.com/v1beta/projects/{project_id}:verifyAppCheckToken'
|
|
58
58
|
)
|
|
59
59
|
_project_id = None
|
|
60
60
|
_scoped_project_id = None
|
|
@@ -15,14 +15,17 @@
|
|
|
15
15
|
"""Firebase Cloud Messaging module."""
|
|
16
16
|
|
|
17
17
|
from __future__ import annotations
|
|
18
|
-
|
|
18
|
+
import asyncio
|
|
19
19
|
import concurrent.futures
|
|
20
20
|
import json
|
|
21
|
-
import asyncio
|
|
22
21
|
import logging
|
|
22
|
+
import re
|
|
23
|
+
from typing import Any, Callable, Dict, List, Optional, Union, cast
|
|
24
|
+
import urllib.parse
|
|
23
25
|
import warnings
|
|
24
|
-
|
|
26
|
+
|
|
25
27
|
import httpx
|
|
28
|
+
import requests
|
|
26
29
|
|
|
27
30
|
import firebase_admin
|
|
28
31
|
from firebase_admin import (
|
|
@@ -73,7 +76,11 @@ __all__ = [
|
|
|
73
76
|
'send_each_for_multicast',
|
|
74
77
|
'send_each_for_multicast_async',
|
|
75
78
|
'subscribe_to_topic',
|
|
79
|
+
'subscribe_to_topic_async',
|
|
80
|
+
'subscribe_to_topic_legacy',
|
|
76
81
|
'unsubscribe_from_topic',
|
|
82
|
+
'unsubscribe_from_topic_async',
|
|
83
|
+
'unsubscribe_from_topic_legacy',
|
|
77
84
|
]
|
|
78
85
|
|
|
79
86
|
|
|
@@ -252,9 +259,53 @@ def send_each_for_multicast(multicast_message, dry_run=False, app=None):
|
|
|
252
259
|
messages = _get_messages_from_multicast(multicast_message)
|
|
253
260
|
return _get_messaging_service(app).send_each(messages, dry_run)
|
|
254
261
|
|
|
255
|
-
def subscribe_to_topic(
|
|
262
|
+
def subscribe_to_topic(
|
|
263
|
+
tokens: Union[str, List[str]], topic: str, app: Optional[App] = None
|
|
264
|
+
) -> TopicManagementResponse:
|
|
256
265
|
"""Subscribes a list of registration tokens to an FCM topic.
|
|
257
266
|
|
|
267
|
+
Args:
|
|
268
|
+
tokens: A non-empty list of device registration tokens. List may not have more than 1000
|
|
269
|
+
elements.
|
|
270
|
+
topic: Name of the topic to subscribe to. May contain the ``/topics/`` prefix.
|
|
271
|
+
app: An App instance (optional).
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
TopicManagementResponse: A ``TopicManagementResponse`` instance.
|
|
275
|
+
|
|
276
|
+
Raises:
|
|
277
|
+
FirebaseError: If an error occurs while communicating with the FCM service.
|
|
278
|
+
ValueError: If the input arguments are invalid.
|
|
279
|
+
"""
|
|
280
|
+
return _get_messaging_service(app).subscribe_to_topic(tokens, topic)
|
|
281
|
+
|
|
282
|
+
async def subscribe_to_topic_async(
|
|
283
|
+
tokens: Union[str, List[str]], topic: str, app: Optional[App] = None
|
|
284
|
+
) -> TopicManagementResponse:
|
|
285
|
+
"""Subscribes a list of registration tokens to an FCM topic asynchronously.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
tokens: A non-empty list of device registration tokens. List may not have more than 1000
|
|
289
|
+
elements.
|
|
290
|
+
topic: Name of the topic to subscribe to. May contain the ``/topics/`` prefix.
|
|
291
|
+
app: An App instance (optional).
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
TopicManagementResponse: A ``TopicManagementResponse`` instance.
|
|
295
|
+
|
|
296
|
+
Raises:
|
|
297
|
+
FirebaseError: If an error occurs while communicating with the FCM service.
|
|
298
|
+
ValueError: If the input arguments are invalid.
|
|
299
|
+
"""
|
|
300
|
+
return await _get_messaging_service(app).subscribe_to_topic_async(tokens, topic)
|
|
301
|
+
|
|
302
|
+
def subscribe_to_topic_legacy(
|
|
303
|
+
tokens: Union[str, List[str]], topic: str, app: Optional[App] = None
|
|
304
|
+
) -> TopicManagementResponse:
|
|
305
|
+
"""Subscribes a list of registration tokens to an FCM topic using the legacy Instance ID API.
|
|
306
|
+
|
|
307
|
+
subscribe_to_topic_legacy is deprecated. Use subscribe_to_topic instead.
|
|
308
|
+
|
|
258
309
|
Args:
|
|
259
310
|
tokens: A non-empty list of device registration tokens. List may not have more than 1000
|
|
260
311
|
elements.
|
|
@@ -268,12 +319,61 @@ def subscribe_to_topic(tokens, topic, app=None):
|
|
|
268
319
|
FirebaseError: If an error occurs while communicating with instance ID service.
|
|
269
320
|
ValueError: If the input arguments are invalid.
|
|
270
321
|
"""
|
|
322
|
+
warnings.warn(
|
|
323
|
+
'subscribe_to_topic_legacy is deprecated. Use subscribe_to_topic instead.',
|
|
324
|
+
DeprecationWarning,
|
|
325
|
+
stacklevel=2)
|
|
271
326
|
return _get_messaging_service(app).make_topic_management_request(
|
|
272
327
|
tokens, topic, 'iid/v1:batchAdd')
|
|
273
328
|
|
|
274
|
-
def unsubscribe_from_topic(
|
|
329
|
+
def unsubscribe_from_topic(
|
|
330
|
+
tokens: Union[str, List[str]], topic: str, app: Optional[App] = None
|
|
331
|
+
) -> TopicManagementResponse:
|
|
275
332
|
"""Unsubscribes a list of registration tokens from an FCM topic.
|
|
276
333
|
|
|
334
|
+
Args:
|
|
335
|
+
tokens: A non-empty list of device registration tokens. List may not have more than 1000
|
|
336
|
+
elements.
|
|
337
|
+
topic: Name of the topic to unsubscribe from. May contain the ``/topics/`` prefix.
|
|
338
|
+
app: An App instance (optional).
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
TopicManagementResponse: A ``TopicManagementResponse`` instance.
|
|
342
|
+
|
|
343
|
+
Raises:
|
|
344
|
+
FirebaseError: If an error occurs while communicating with the FCM service.
|
|
345
|
+
ValueError: If the input arguments are invalid.
|
|
346
|
+
"""
|
|
347
|
+
return _get_messaging_service(app).unsubscribe_from_topic(tokens, topic)
|
|
348
|
+
|
|
349
|
+
async def unsubscribe_from_topic_async(
|
|
350
|
+
tokens: Union[str, List[str]], topic: str, app: Optional[App] = None
|
|
351
|
+
) -> TopicManagementResponse:
|
|
352
|
+
"""Unsubscribes a list of registration tokens from an FCM topic asynchronously.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
tokens: A non-empty list of device registration tokens. List may not have more than 1000
|
|
356
|
+
elements.
|
|
357
|
+
topic: Name of the topic to unsubscribe from. May contain the ``/topics/`` prefix.
|
|
358
|
+
app: An App instance (optional).
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
TopicManagementResponse: A ``TopicManagementResponse`` instance.
|
|
362
|
+
|
|
363
|
+
Raises:
|
|
364
|
+
FirebaseError: If an error occurs while communicating with the FCM service.
|
|
365
|
+
ValueError: If the input arguments are invalid.
|
|
366
|
+
"""
|
|
367
|
+
return await _get_messaging_service(app).unsubscribe_from_topic_async(tokens, topic)
|
|
368
|
+
|
|
369
|
+
def unsubscribe_from_topic_legacy(
|
|
370
|
+
tokens: Union[str, List[str]], topic: str, app: Optional[App] = None
|
|
371
|
+
) -> TopicManagementResponse:
|
|
372
|
+
"""Unsubscribes a list of registration tokens from an FCM topic using the legacy
|
|
373
|
+
Instance ID API.
|
|
374
|
+
|
|
375
|
+
unsubscribe_from_topic_legacy is deprecated. Use unsubscribe_from_topic instead.
|
|
376
|
+
|
|
277
377
|
Args:
|
|
278
378
|
tokens: A non-empty list of device registration tokens. List may not have more than 1000
|
|
279
379
|
elements.
|
|
@@ -287,6 +387,10 @@ def unsubscribe_from_topic(tokens, topic, app=None):
|
|
|
287
387
|
FirebaseError: If an error occurs while communicating with instance ID service.
|
|
288
388
|
ValueError: If the input arguments are invalid.
|
|
289
389
|
"""
|
|
390
|
+
warnings.warn(
|
|
391
|
+
'unsubscribe_from_topic_legacy is deprecated. Use unsubscribe_from_topic instead.',
|
|
392
|
+
DeprecationWarning,
|
|
393
|
+
stacklevel=2)
|
|
290
394
|
return _get_messaging_service(app).make_topic_management_request(
|
|
291
395
|
tokens, topic, 'iid/v1:batchRemove')
|
|
292
396
|
|
|
@@ -410,7 +514,9 @@ class _MessagingService:
|
|
|
410
514
|
'Project ID is required to access Cloud Messaging service. Either set the '
|
|
411
515
|
'projectId option, or use service account credentials. Alternatively, set the '
|
|
412
516
|
'GOOGLE_CLOUD_PROJECT environment variable.')
|
|
517
|
+
self._project_id = project_id
|
|
413
518
|
self._fcm_url = _MessagingService.FCM_URL.format(project_id)
|
|
519
|
+
self._fcm_topic_url = f'https://fcm.googleapis.com/v1/projects/{project_id}/registrations'
|
|
414
520
|
self._fcm_headers = {
|
|
415
521
|
'X-GOOG-API-FORMAT-VERSION': '2',
|
|
416
522
|
'X-FIREBASE-CLIENT': f'fire-admin-python/{firebase_admin.__version__}',
|
|
@@ -418,6 +524,12 @@ class _MessagingService:
|
|
|
418
524
|
timeout = app.options.get('httpTimeout', _http_client.DEFAULT_TIMEOUT_SECONDS)
|
|
419
525
|
self._credential = app.credential.get_credential()
|
|
420
526
|
self._client = _http_client.JsonHttpClient(credential=self._credential, timeout=timeout)
|
|
527
|
+
fcm_adapter = requests.adapters.HTTPAdapter(
|
|
528
|
+
pool_connections=100,
|
|
529
|
+
pool_maxsize=100,
|
|
530
|
+
max_retries=_http_client.DEFAULT_RETRY_CONFIG
|
|
531
|
+
)
|
|
532
|
+
self._client.session.mount('https://fcm.googleapis.com', fcm_adapter)
|
|
421
533
|
self._async_client = _http_client.HttpxAsyncClient(
|
|
422
534
|
credential=self._credential, timeout=timeout)
|
|
423
535
|
|
|
@@ -434,7 +546,7 @@ class _MessagingService:
|
|
|
434
546
|
resp = self._client.body(
|
|
435
547
|
'post',
|
|
436
548
|
url=self._fcm_url,
|
|
437
|
-
headers=self._fcm_headers,
|
|
549
|
+
headers=dict(self._fcm_headers),
|
|
438
550
|
json=data
|
|
439
551
|
)
|
|
440
552
|
except requests.exceptions.RequestException as error:
|
|
@@ -453,7 +565,7 @@ class _MessagingService:
|
|
|
453
565
|
resp = self._client.body(
|
|
454
566
|
'post',
|
|
455
567
|
url=self._fcm_url,
|
|
456
|
-
headers=self._fcm_headers,
|
|
568
|
+
headers=dict(self._fcm_headers),
|
|
457
569
|
json=data)
|
|
458
570
|
except requests.exceptions.RequestException as exception:
|
|
459
571
|
return SendResponse(resp=None, exception=self._handle_fcm_error(exception))
|
|
@@ -481,7 +593,7 @@ class _MessagingService:
|
|
|
481
593
|
resp = await self._async_client.request(
|
|
482
594
|
'post',
|
|
483
595
|
url=self._fcm_url,
|
|
484
|
-
headers=self._fcm_headers,
|
|
596
|
+
headers=dict(self._fcm_headers),
|
|
485
597
|
json=data)
|
|
486
598
|
except httpx.HTTPError as exception:
|
|
487
599
|
return SendResponse(resp=None, exception=self._handle_fcm_httpx_error(exception))
|
|
@@ -499,6 +611,225 @@ class _MessagingService:
|
|
|
499
611
|
message=f'Unknown error while making remote service calls: {error}',
|
|
500
612
|
cause=error)
|
|
501
613
|
|
|
614
|
+
def _validate_topic_management_args(self, tokens, topic):
|
|
615
|
+
"""Validates and formats topic management arguments."""
|
|
616
|
+
if isinstance(tokens, str):
|
|
617
|
+
tokens = [tokens]
|
|
618
|
+
if not isinstance(tokens, list) or not tokens:
|
|
619
|
+
raise ValueError('Tokens must be a string or a non-empty list of strings.')
|
|
620
|
+
invalid_str = [t for t in tokens if not isinstance(t, str) or not t]
|
|
621
|
+
if invalid_str:
|
|
622
|
+
raise ValueError('Tokens must be non-empty strings.')
|
|
623
|
+
if len(tokens) > 1000:
|
|
624
|
+
raise ValueError('tokens must not contain more than 1000 elements.')
|
|
625
|
+
|
|
626
|
+
if not isinstance(topic, str) or not topic:
|
|
627
|
+
raise ValueError('Topic must be a non-empty string.')
|
|
628
|
+
topic_name = topic
|
|
629
|
+
if topic_name.startswith('/topics/'):
|
|
630
|
+
topic_name = topic_name[len('/topics/'):]
|
|
631
|
+
if not topic_name or not re.match(r'^[a-zA-Z0-9-_\.~%]+\Z', topic_name):
|
|
632
|
+
raise ValueError('Malformed topic name.')
|
|
633
|
+
|
|
634
|
+
return tokens, topic_name
|
|
635
|
+
|
|
636
|
+
def subscribe_to_topic(
|
|
637
|
+
self, tokens: Union[str, List[str]], topic: str
|
|
638
|
+
) -> TopicManagementResponse:
|
|
639
|
+
"""Subscribes a list of registration tokens to an FCM topic via the FCM v1 API."""
|
|
640
|
+
return self._make_topic_management_request_v1(tokens, topic, is_subscribe=True)
|
|
641
|
+
|
|
642
|
+
def unsubscribe_from_topic(
|
|
643
|
+
self, tokens: Union[str, List[str]], topic: str
|
|
644
|
+
) -> TopicManagementResponse:
|
|
645
|
+
"""Unsubscribes a list of registration tokens from an FCM topic via the FCM v1 API."""
|
|
646
|
+
return self._make_topic_management_request_v1(tokens, topic, is_subscribe=False)
|
|
647
|
+
|
|
648
|
+
async def subscribe_to_topic_async(
|
|
649
|
+
self, tokens: Union[str, List[str]], topic: str
|
|
650
|
+
) -> TopicManagementResponse:
|
|
651
|
+
"""Subscribes a list of registration tokens to an FCM topic asynchronously
|
|
652
|
+
via the FCM v1 API."""
|
|
653
|
+
return await self._make_topic_management_request_v1_async(
|
|
654
|
+
tokens, topic, is_subscribe=True)
|
|
655
|
+
|
|
656
|
+
async def unsubscribe_from_topic_async(
|
|
657
|
+
self, tokens: Union[str, List[str]], topic: str
|
|
658
|
+
) -> TopicManagementResponse:
|
|
659
|
+
"""Unsubscribes a list of registration tokens from an FCM topic asynchronously
|
|
660
|
+
via the FCM v1 API."""
|
|
661
|
+
return await self._make_topic_management_request_v1_async(
|
|
662
|
+
tokens, topic, is_subscribe=False)
|
|
663
|
+
|
|
664
|
+
def _make_topic_management_request_v1(
|
|
665
|
+
self, tokens, topic, is_subscribe: bool
|
|
666
|
+
) -> TopicManagementResponse:
|
|
667
|
+
"""Helper method that sends topic subscription requests via FCM v1 API."""
|
|
668
|
+
tokens_list, topic_name = self._validate_topic_management_args(tokens, topic)
|
|
669
|
+
encoded_topic = urllib.parse.quote(topic_name, safe='')
|
|
670
|
+
|
|
671
|
+
def send_request(token: str):
|
|
672
|
+
encoded_token = urllib.parse.quote(token, safe='')
|
|
673
|
+
base_url = f'{self._fcm_topic_url}/{encoded_token}/topicSubscriptions'
|
|
674
|
+
if is_subscribe:
|
|
675
|
+
url = f'{base_url}?topic_name={encoded_topic}'
|
|
676
|
+
method = 'post'
|
|
677
|
+
json_data = {}
|
|
678
|
+
else:
|
|
679
|
+
url = f'{base_url}/{encoded_topic}?allow_missing=true'
|
|
680
|
+
method = 'delete'
|
|
681
|
+
json_data = None
|
|
682
|
+
|
|
683
|
+
try:
|
|
684
|
+
self._client.request(
|
|
685
|
+
method,
|
|
686
|
+
url=url,
|
|
687
|
+
headers=dict(self._fcm_headers),
|
|
688
|
+
json=json_data,
|
|
689
|
+
)
|
|
690
|
+
return {'success': True}
|
|
691
|
+
except requests.exceptions.RequestException as error:
|
|
692
|
+
return self._build_topic_subscription_result_from_requests_error(
|
|
693
|
+
error, is_subscribe)
|
|
694
|
+
|
|
695
|
+
try:
|
|
696
|
+
with concurrent.futures.ThreadPoolExecutor(
|
|
697
|
+
max_workers=min(len(tokens_list), 100)
|
|
698
|
+
) as executor:
|
|
699
|
+
results = list(executor.map(send_request, tokens_list))
|
|
700
|
+
return self._parse_topic_management_results(results)
|
|
701
|
+
except Exception as error:
|
|
702
|
+
raise exceptions.UnknownError(
|
|
703
|
+
message=f'Unknown error while making remote service calls: {error}',
|
|
704
|
+
cause=error)
|
|
705
|
+
|
|
706
|
+
async def _make_topic_management_request_v1_async(
|
|
707
|
+
self, tokens, topic, is_subscribe: bool
|
|
708
|
+
) -> TopicManagementResponse:
|
|
709
|
+
"""Helper method that sends topic subscription requests asynchronously via FCM v1 API."""
|
|
710
|
+
tokens_list, topic_name = self._validate_topic_management_args(tokens, topic)
|
|
711
|
+
encoded_topic = urllib.parse.quote(topic_name, safe='')
|
|
712
|
+
semaphore = asyncio.Semaphore(100)
|
|
713
|
+
|
|
714
|
+
async def send_request_async(token: str):
|
|
715
|
+
encoded_token = urllib.parse.quote(token, safe='')
|
|
716
|
+
base_url = f'{self._fcm_topic_url}/{encoded_token}/topicSubscriptions'
|
|
717
|
+
if is_subscribe:
|
|
718
|
+
url = f'{base_url}?topic_name={encoded_topic}'
|
|
719
|
+
method = 'post'
|
|
720
|
+
json_data = {}
|
|
721
|
+
else:
|
|
722
|
+
url = f'{base_url}/{encoded_topic}?allow_missing=true'
|
|
723
|
+
method = 'delete'
|
|
724
|
+
json_data = None
|
|
725
|
+
|
|
726
|
+
async with semaphore:
|
|
727
|
+
try:
|
|
728
|
+
await self._async_client.request(
|
|
729
|
+
method,
|
|
730
|
+
url=url,
|
|
731
|
+
headers=dict(self._fcm_headers),
|
|
732
|
+
json=json_data,
|
|
733
|
+
)
|
|
734
|
+
return {'success': True}
|
|
735
|
+
except httpx.HTTPError as error:
|
|
736
|
+
return self._build_topic_subscription_result_from_httpx_error(
|
|
737
|
+
error, is_subscribe)
|
|
738
|
+
except requests.exceptions.RequestException as error:
|
|
739
|
+
return self._build_topic_subscription_result_from_requests_error(
|
|
740
|
+
error, is_subscribe)
|
|
741
|
+
|
|
742
|
+
try:
|
|
743
|
+
results = await asyncio.gather(*[send_request_async(token) for token in tokens_list])
|
|
744
|
+
return self._parse_topic_management_results(results)
|
|
745
|
+
except Exception as error:
|
|
746
|
+
raise exceptions.UnknownError(
|
|
747
|
+
message=f'Unknown error while making remote service calls: {error}',
|
|
748
|
+
cause=error)
|
|
749
|
+
|
|
750
|
+
@classmethod
|
|
751
|
+
def _get_topic_error_code(cls, error_dict: dict, status_code: int) -> str:
|
|
752
|
+
"""Extracts the error code for a topic subscription error response."""
|
|
753
|
+
error_data = error_dict.get('error')
|
|
754
|
+
if isinstance(error_data, str) and error_data:
|
|
755
|
+
return error_data
|
|
756
|
+
if isinstance(error_data, dict):
|
|
757
|
+
details = error_data.get('details')
|
|
758
|
+
if isinstance(details, list):
|
|
759
|
+
fcm_error_type = 'type.googleapis.com/google.firebase.fcm.v1.FcmError'
|
|
760
|
+
for element in details:
|
|
761
|
+
if isinstance(element, dict) and element.get('@type') == fcm_error_type:
|
|
762
|
+
code = element.get('errorCode')
|
|
763
|
+
if code:
|
|
764
|
+
return code
|
|
765
|
+
status = error_data.get('status')
|
|
766
|
+
if status:
|
|
767
|
+
return status
|
|
768
|
+
|
|
769
|
+
status_map = {
|
|
770
|
+
400: 'INVALID_ARGUMENT',
|
|
771
|
+
401: 'UNAUTHENTICATED',
|
|
772
|
+
403: 'PERMISSION_DENIED',
|
|
773
|
+
404: 'NOT_FOUND',
|
|
774
|
+
408: 'DEADLINE_EXCEEDED',
|
|
775
|
+
429: 'RESOURCE_EXHAUSTED',
|
|
776
|
+
500: 'INTERNAL',
|
|
777
|
+
503: 'UNAVAILABLE',
|
|
778
|
+
504: 'DEADLINE_EXCEEDED',
|
|
779
|
+
}
|
|
780
|
+
if status_code in status_map:
|
|
781
|
+
return status_map[status_code]
|
|
782
|
+
|
|
783
|
+
if isinstance(error_data, dict):
|
|
784
|
+
message = error_data.get('message')
|
|
785
|
+
if message:
|
|
786
|
+
return message
|
|
787
|
+
|
|
788
|
+
return 'UNKNOWN_ERROR'
|
|
789
|
+
|
|
790
|
+
def _build_topic_subscription_result(self, response, is_subscribe):
|
|
791
|
+
"""Constructs a result dict from a response object."""
|
|
792
|
+
if response is not None:
|
|
793
|
+
if is_subscribe and response.status_code == 409:
|
|
794
|
+
return {'success': True}
|
|
795
|
+
error_dict = {}
|
|
796
|
+
try:
|
|
797
|
+
parsed = response.json()
|
|
798
|
+
if isinstance(parsed, dict):
|
|
799
|
+
error_dict = parsed
|
|
800
|
+
except ValueError:
|
|
801
|
+
pass
|
|
802
|
+
|
|
803
|
+
error_data = error_dict.get('error')
|
|
804
|
+
if is_subscribe and isinstance(error_data, dict) and (
|
|
805
|
+
error_data.get('status') == 'ALREADY_EXISTS'
|
|
806
|
+
):
|
|
807
|
+
return {'success': True}
|
|
808
|
+
|
|
809
|
+
error_code = self._get_topic_error_code(error_dict, response.status_code)
|
|
810
|
+
return {'success': False, 'error': error_code}
|
|
811
|
+
|
|
812
|
+
return {'success': False, 'error': 'UNKNOWN_ERROR'}
|
|
813
|
+
|
|
814
|
+
def _build_topic_subscription_result_from_requests_error(self, error, is_subscribe):
|
|
815
|
+
"""Constructs a result dict from a requests error."""
|
|
816
|
+
return self._build_topic_subscription_result(error.response, is_subscribe)
|
|
817
|
+
|
|
818
|
+
def _build_topic_subscription_result_from_httpx_error(self, error, is_subscribe):
|
|
819
|
+
"""Constructs a result dict from an httpx error."""
|
|
820
|
+
response = error.response if isinstance(error, httpx.HTTPStatusError) else None
|
|
821
|
+
return self._build_topic_subscription_result(response, is_subscribe)
|
|
822
|
+
|
|
823
|
+
def _parse_topic_management_results(self, results) -> TopicManagementResponse:
|
|
824
|
+
"""Parses individual request results into a TopicManagementResponse."""
|
|
825
|
+
formatted_results = []
|
|
826
|
+
for result in results:
|
|
827
|
+
if result.get('success'):
|
|
828
|
+
formatted_results.append({})
|
|
829
|
+
else:
|
|
830
|
+
formatted_results.append({'error': result.get('error', 'UNKNOWN_ERROR')})
|
|
831
|
+
return TopicManagementResponse({'results': formatted_results})
|
|
832
|
+
|
|
502
833
|
def make_topic_management_request(self, tokens, topic, operation):
|
|
503
834
|
"""Invokes the IID service for topic management functionality."""
|
|
504
835
|
if isinstance(tokens, str):
|
|
@@ -319,7 +319,8 @@ class TestVerifyToken(TestBatch):
|
|
|
319
319
|
assert payload == expected
|
|
320
320
|
|
|
321
321
|
expected_url = (
|
|
322
|
-
f"https://firebaseappcheck.googleapis.com/
|
|
322
|
+
f"https://firebaseappcheck.googleapis.com/v1beta/projects/{PROJECT_ID}"
|
|
323
|
+
":verifyAppCheckToken"
|
|
323
324
|
)
|
|
324
325
|
mock_body.assert_called_once_with(
|
|
325
326
|
"post", expected_url, json={"app_check_token": "encoded"}
|
|
@@ -340,7 +341,8 @@ class TestVerifyToken(TestBatch):
|
|
|
340
341
|
assert payload == expected
|
|
341
342
|
|
|
342
343
|
expected_url = (
|
|
343
|
-
f"https://firebaseappcheck.googleapis.com/
|
|
344
|
+
f"https://firebaseappcheck.googleapis.com/v1beta/projects/{PROJECT_ID}"
|
|
345
|
+
":verifyAppCheckToken"
|
|
344
346
|
)
|
|
345
347
|
mock_body.assert_called_once_with(
|
|
346
348
|
"post", expected_url, json={"app_check_token": "encoded"}
|