firebase-admin 7.5.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.
Files changed (66) hide show
  1. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/PKG-INFO +1 -1
  2. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/firebase_admin/__about__.py +1 -1
  3. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/firebase_admin/app_check.py +53 -9
  4. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/firebase_admin/messaging.py +339 -8
  5. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/firebase_admin/ml.py +140 -4
  6. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/firebase_admin.egg-info/PKG-INFO +1 -1
  7. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/tests/test_app_check.py +138 -5
  8. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/tests/test_messaging.py +564 -18
  9. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/tests/test_ml.py +87 -0
  10. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/LICENSE +0 -0
  11. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/README.md +0 -0
  12. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/firebase_admin/__init__.py +0 -0
  13. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/firebase_admin/_auth_client.py +0 -0
  14. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/firebase_admin/_auth_providers.py +0 -0
  15. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/firebase_admin/_auth_utils.py +0 -0
  16. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/firebase_admin/_http_client.py +0 -0
  17. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/firebase_admin/_messaging_encoder.py +0 -0
  18. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/firebase_admin/_messaging_utils.py +0 -0
  19. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/firebase_admin/_retry.py +0 -0
  20. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/firebase_admin/_rfc3339.py +0 -0
  21. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/firebase_admin/_sseclient.py +0 -0
  22. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/firebase_admin/_token_gen.py +0 -0
  23. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/firebase_admin/_user_identifier.py +0 -0
  24. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/firebase_admin/_user_import.py +0 -0
  25. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/firebase_admin/_user_mgt.py +0 -0
  26. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/firebase_admin/_utils.py +0 -0
  27. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/firebase_admin/auth.py +0 -0
  28. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/firebase_admin/credentials.py +0 -0
  29. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/firebase_admin/db.py +0 -0
  30. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/firebase_admin/exceptions.py +0 -0
  31. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/firebase_admin/firestore.py +0 -0
  32. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/firebase_admin/firestore_async.py +0 -0
  33. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/firebase_admin/functions.py +0 -0
  34. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/firebase_admin/instance_id.py +0 -0
  35. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/firebase_admin/phone_number_verification.py +0 -0
  36. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/firebase_admin/project_management.py +0 -0
  37. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/firebase_admin/remote_config.py +0 -0
  38. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/firebase_admin/storage.py +0 -0
  39. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/firebase_admin/tenant_mgt.py +0 -0
  40. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/firebase_admin.egg-info/SOURCES.txt +0 -0
  41. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/firebase_admin.egg-info/dependency_links.txt +0 -0
  42. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/firebase_admin.egg-info/requires.txt +0 -0
  43. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/firebase_admin.egg-info/top_level.txt +0 -0
  44. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/setup.cfg +0 -0
  45. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/setup.py +0 -0
  46. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/tests/test_app.py +0 -0
  47. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/tests/test_auth_providers.py +0 -0
  48. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/tests/test_credentials.py +0 -0
  49. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/tests/test_db.py +0 -0
  50. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/tests/test_exceptions.py +0 -0
  51. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/tests/test_firestore.py +0 -0
  52. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/tests/test_firestore_async.py +0 -0
  53. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/tests/test_functions.py +0 -0
  54. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/tests/test_http_client.py +0 -0
  55. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/tests/test_instance_id.py +0 -0
  56. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/tests/test_phone_number_verification.py +0 -0
  57. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/tests/test_project_management.py +0 -0
  58. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/tests/test_remote_config.py +0 -0
  59. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/tests/test_retry.py +0 -0
  60. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/tests/test_rfc3339.py +0 -0
  61. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/tests/test_sseclient.py +0 -0
  62. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/tests/test_storage.py +0 -0
  63. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/tests/test_tenant_mgt.py +0 -0
  64. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/tests/test_token_gen.py +0 -0
  65. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/tests/test_user_mgt.py +0 -0
  66. {firebase_admin-7.5.0 → firebase_admin-7.7.0}/tests/testutils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: firebase_admin
3
- Version: 7.5.0
3
+ Version: 7.7.0
4
4
  Summary: Firebase Admin Python SDK
5
5
  Home-page: https://firebase.google.com/docs/admin/setup/
6
6
  Author: Firebase
@@ -14,7 +14,7 @@
14
14
 
15
15
  """About information (version, etc) for Firebase Admin SDK."""
16
16
 
17
- __version__ = '7.5.0'
17
+ __version__ = '7.7.0'
18
18
  __title__ = 'firebase_admin'
19
19
  __author__ = 'Firebase'
20
20
  __license__ = 'Apache License 2.0'
@@ -15,38 +15,47 @@
15
15
  """Firebase App Check module."""
16
16
 
17
17
  from typing import Any, Dict
18
+ import requests
18
19
  import jwt
19
20
  from jwt import PyJWKClient, ExpiredSignatureError, InvalidTokenError, DecodeError
20
21
  from jwt import InvalidAudienceError, InvalidIssuerError, InvalidSignatureError
21
- from firebase_admin import _utils
22
+ from firebase_admin import _http_client, _utils, exceptions
22
23
 
23
24
  _APP_CHECK_ATTRIBUTE = '_app_check'
24
25
 
25
26
  def _get_app_check_service(app) -> Any:
26
27
  return _utils.get_app_service(app, _APP_CHECK_ATTRIBUTE, _AppCheckService)
27
28
 
28
- def verify_token(token: str, app=None) -> Dict[str, Any]:
29
- """Verifies a Firebase App Check token.
29
+ def verify_token(token: str, app=None, consume: bool = False) -> Dict[str, Any]:
30
+ """Verifies a Firebase App Check token, optionally consuming limited-use tokens.
30
31
 
31
32
  Args:
32
33
  token: A token from App Check.
33
34
  app: An App instance (optional).
35
+ consume: Set to ``True`` only if the token is a limited-use (one-time) token
36
+ that should be consumed upon verification (optional, defaults to ``False``).
34
37
 
35
38
  Returns:
36
- Dict[str, Any]: The token's decoded claims.
39
+ Dict[str, Any]: The token's decoded claims. If ``consume`` is ``True``, the dictionary
40
+ also includes an ``already_consumed`` boolean key indicating whether the token was
41
+ previously consumed.
37
42
 
38
43
  Raises:
39
- ValueError: If the app's ``project_id`` is invalid or unspecified,
40
- or if the token's headers or payload are invalid.
44
+ ValueError: If ``consume`` is not a boolean, or if the app's ``project_id``
45
+ is invalid or unspecified, or if the token's headers or payload are invalid.
46
+ FirebaseError: If an error occurs while communicating with the App Check service.
41
47
  PyJWKClientError: If PyJWKClient fails to fetch a valid signing key.
42
48
  """
43
- return _get_app_check_service(app).verify_token(token)
49
+ return _get_app_check_service(app).verify_token(token, consume=consume)
44
50
 
45
51
  class _AppCheckService:
46
52
  """Service class that implements Firebase App Check functionality."""
47
53
 
48
54
  _APP_CHECK_ISSUER = 'https://firebaseappcheck.googleapis.com/'
49
55
  _JWKS_URL = 'https://firebaseappcheck.googleapis.com/v1/jwks'
56
+ _VERIFY_URL_FORMAT = (
57
+ 'https://firebaseappcheck.googleapis.com/v1beta/projects/{project_id}:verifyAppCheckToken'
58
+ )
50
59
  _project_id = None
51
60
  _scoped_project_id = None
52
61
  _jwks_client = None
@@ -68,11 +77,15 @@ class _AppCheckService:
68
77
  # Default lifespan is 300 seconds (5 minutes) so we change it to 21600 seconds (6 hours).
69
78
  self._jwks_client = PyJWKClient(
70
79
  self._JWKS_URL, lifespan=21600, headers=self._APP_CHECK_HEADERS)
80
+ timeout = app.options.get('httpTimeout', _http_client.DEFAULT_TIMEOUT_SECONDS)
81
+ self._http_client = _http_client.JsonHttpClient(
82
+ credential=app.credential.get_credential(), timeout=timeout)
71
83
 
72
84
 
73
- def verify_token(self, token: str) -> Dict[str, Any]:
74
- """Verifies a Firebase App Check token."""
85
+ def verify_token(self, token: str, consume: bool = False) -> Dict[str, Any]:
86
+ """Verifies a Firebase App Check token, optionally consuming limited-use tokens."""
75
87
  _Validators.check_string("app check token", token)
88
+ _Validators.check_boolean("consume", consume)
76
89
 
77
90
  # Obtain the Firebase App Check Public Keys
78
91
  # Note: It is not recommended to hard code these keys as they rotate,
@@ -87,8 +100,32 @@ class _AppCheckService:
87
100
  ) from exception
88
101
 
89
102
  verified_claims['app_id'] = verified_claims.get('sub')
103
+
104
+ if consume:
105
+ verified_claims['already_consumed'] = self._verify_replay_protection(token)
106
+
90
107
  return verified_claims
91
108
 
109
+ def _verify_replay_protection(self, token: str) -> bool:
110
+ """Verifies replay protection with the backend and returns the alreadyConsumed status."""
111
+ url = self._VERIFY_URL_FORMAT.format(project_id=self._project_id)
112
+ try:
113
+ body = self._http_client.body('post', url, json={'app_check_token': token})
114
+ except requests.exceptions.RequestException as error:
115
+ raise _utils.handle_platform_error_from_requests(error)
116
+ except ValueError as error:
117
+ raise exceptions.UnknownError(
118
+ f'Unexpected response from App Check service: {error}'
119
+ ) from error
120
+
121
+ if not isinstance(body, dict):
122
+ raise exceptions.UnknownError(
123
+ 'Unexpected response from App Check service. '
124
+ f'Expected a JSON object, but got {type(body).__name__}.'
125
+ )
126
+
127
+ return bool(body.get('alreadyConsumed', False))
128
+
92
129
  def _has_valid_token_headers(self, headers: Any) -> None:
93
130
  """Checks whether the token has valid headers for App Check."""
94
131
  # Ensure the token's header has type JWT
@@ -159,3 +196,10 @@ class _Validators:
159
196
  raise ValueError(f'{label} "{value}" must be a non-empty string.')
160
197
  if not isinstance(value, str):
161
198
  raise ValueError(f'{label} "{value}" must be a string.')
199
+
200
+ @classmethod
201
+ def check_boolean(cls, label: str, value: Any):
202
+ """Checks if the given value is a boolean."""
203
+ if not isinstance(value, bool):
204
+ raise ValueError(f'{label} must be a boolean.')
205
+ return value
@@ -15,14 +15,17 @@
15
15
  """Firebase Cloud Messaging module."""
16
16
 
17
17
  from __future__ import annotations
18
- from typing import Any, Callable, Dict, List, Optional, cast
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
- import requests
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(tokens, topic, app=None):
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(tokens, topic, app=None):
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):