localstack-core 4.10.1.dev7__py3-none-any.whl → 4.11.2.dev14__py3-none-any.whl

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 (152) hide show
  1. localstack/aws/api/acm/__init__.py +122 -122
  2. localstack/aws/api/apigateway/__init__.py +604 -561
  3. localstack/aws/api/cloudcontrol/__init__.py +63 -63
  4. localstack/aws/api/cloudformation/__init__.py +1201 -969
  5. localstack/aws/api/cloudwatch/__init__.py +375 -375
  6. localstack/aws/api/config/__init__.py +784 -786
  7. localstack/aws/api/dynamodb/__init__.py +753 -759
  8. localstack/aws/api/dynamodbstreams/__init__.py +74 -74
  9. localstack/aws/api/ec2/__init__.py +10062 -8826
  10. localstack/aws/api/es/__init__.py +453 -453
  11. localstack/aws/api/events/__init__.py +552 -552
  12. localstack/aws/api/firehose/__init__.py +541 -543
  13. localstack/aws/api/iam/__init__.py +866 -572
  14. localstack/aws/api/kinesis/__init__.py +235 -147
  15. localstack/aws/api/kms/__init__.py +341 -336
  16. localstack/aws/api/lambda_/__init__.py +974 -621
  17. localstack/aws/api/logs/__init__.py +988 -675
  18. localstack/aws/api/opensearch/__init__.py +903 -785
  19. localstack/aws/api/pipes/__init__.py +336 -336
  20. localstack/aws/api/redshift/__init__.py +1257 -1166
  21. localstack/aws/api/resource_groups/__init__.py +175 -175
  22. localstack/aws/api/resourcegroupstaggingapi/__init__.py +103 -67
  23. localstack/aws/api/route53/__init__.py +296 -254
  24. localstack/aws/api/route53resolver/__init__.py +397 -396
  25. localstack/aws/api/s3/__init__.py +1412 -1349
  26. localstack/aws/api/s3control/__init__.py +594 -594
  27. localstack/aws/api/scheduler/__init__.py +118 -118
  28. localstack/aws/api/secretsmanager/__init__.py +221 -216
  29. localstack/aws/api/ses/__init__.py +227 -227
  30. localstack/aws/api/sns/__init__.py +115 -115
  31. localstack/aws/api/sqs/__init__.py +100 -100
  32. localstack/aws/api/ssm/__init__.py +1977 -1971
  33. localstack/aws/api/stepfunctions/__init__.py +375 -333
  34. localstack/aws/api/sts/__init__.py +142 -66
  35. localstack/aws/api/support/__init__.py +112 -112
  36. localstack/aws/api/swf/__init__.py +378 -386
  37. localstack/aws/api/transcribe/__init__.py +425 -425
  38. localstack/aws/handlers/logging.py +8 -4
  39. localstack/aws/handlers/service.py +22 -3
  40. localstack/aws/protocol/parser.py +1 -1
  41. localstack/aws/protocol/serializer.py +1 -1
  42. localstack/aws/scaffold.py +15 -17
  43. localstack/cli/localstack.py +6 -1
  44. localstack/deprecations.py +0 -6
  45. localstack/dev/kubernetes/__main__.py +38 -3
  46. localstack/services/acm/provider.py +4 -0
  47. localstack/services/apigateway/helpers.py +5 -9
  48. localstack/services/apigateway/legacy/provider.py +60 -24
  49. localstack/services/apigateway/patches.py +0 -9
  50. localstack/services/cloudformation/engine/template_preparer.py +6 -2
  51. localstack/services/cloudformation/engine/v2/change_set_model_preproc.py +12 -0
  52. localstack/services/cloudformation/provider.py +2 -2
  53. localstack/services/cloudformation/v2/provider.py +6 -6
  54. localstack/services/cloudwatch/provider.py +10 -3
  55. localstack/services/cloudwatch/provider_v2.py +6 -3
  56. localstack/services/configservice/provider.py +5 -1
  57. localstack/services/dynamodb/provider.py +1 -0
  58. localstack/services/dynamodb/v2/provider.py +1 -0
  59. localstack/services/dynamodbstreams/provider.py +6 -0
  60. localstack/services/dynamodbstreams/v2/provider.py +6 -0
  61. localstack/services/ec2/provider.py +6 -0
  62. localstack/services/es/provider.py +6 -0
  63. localstack/services/events/provider.py +4 -0
  64. localstack/services/events/v1/provider.py +9 -0
  65. localstack/services/firehose/provider.py +5 -0
  66. localstack/services/iam/provider.py +4 -0
  67. localstack/services/kinesis/packages.py +1 -1
  68. localstack/services/kms/models.py +44 -24
  69. localstack/services/kms/provider.py +97 -16
  70. localstack/services/lambda_/api_utils.py +40 -21
  71. localstack/services/lambda_/event_source_mapping/pollers/stream_poller.py +1 -1
  72. localstack/services/lambda_/invocation/assignment.py +4 -1
  73. localstack/services/lambda_/invocation/execution_environment.py +21 -2
  74. localstack/services/lambda_/invocation/lambda_models.py +27 -2
  75. localstack/services/lambda_/invocation/lambda_service.py +51 -3
  76. localstack/services/lambda_/invocation/models.py +9 -1
  77. localstack/services/lambda_/invocation/version_manager.py +18 -3
  78. localstack/services/lambda_/packages.py +1 -1
  79. localstack/services/lambda_/provider.py +240 -96
  80. localstack/services/lambda_/resource_providers/aws_lambda_function.py +33 -1
  81. localstack/services/lambda_/runtimes.py +10 -3
  82. localstack/services/logs/provider.py +45 -19
  83. localstack/services/opensearch/provider.py +53 -3
  84. localstack/services/resource_groups/provider.py +5 -1
  85. localstack/services/resourcegroupstaggingapi/provider.py +6 -1
  86. localstack/services/s3/provider.py +29 -16
  87. localstack/services/s3/utils.py +35 -14
  88. localstack/services/s3control/provider.py +101 -2
  89. localstack/services/s3control/validation.py +50 -0
  90. localstack/services/sns/constants.py +3 -1
  91. localstack/services/sns/publisher.py +15 -6
  92. localstack/services/sns/v2/models.py +30 -1
  93. localstack/services/sns/v2/provider.py +794 -31
  94. localstack/services/sns/v2/utils.py +20 -0
  95. localstack/services/sqs/models.py +37 -10
  96. localstack/services/stepfunctions/asl/component/common/path/result_path.py +1 -1
  97. localstack/services/stepfunctions/asl/component/state/state_execution/execute_state.py +0 -1
  98. localstack/services/stepfunctions/asl/component/state/state_execution/state_map/state_map.py +0 -1
  99. localstack/services/stepfunctions/asl/component/state/state_execution/state_task/lambda_eval_utils.py +8 -8
  100. localstack/services/stepfunctions/asl/component/state/state_execution/state_task/{mock_eval_utils.py → local_mock_eval_utils.py} +13 -9
  101. localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service.py +6 -6
  102. localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_callback.py +1 -1
  103. localstack/services/stepfunctions/asl/component/state/state_fail/state_fail.py +4 -0
  104. localstack/services/stepfunctions/asl/component/test_state/state/base_mock.py +118 -0
  105. localstack/services/stepfunctions/asl/component/test_state/state/common.py +82 -0
  106. localstack/services/stepfunctions/asl/component/test_state/state/execution.py +139 -0
  107. localstack/services/stepfunctions/asl/component/test_state/state/map.py +77 -0
  108. localstack/services/stepfunctions/asl/component/test_state/state/task.py +44 -0
  109. localstack/services/stepfunctions/asl/eval/environment.py +30 -22
  110. localstack/services/stepfunctions/asl/eval/states.py +1 -1
  111. localstack/services/stepfunctions/asl/eval/test_state/environment.py +49 -9
  112. localstack/services/stepfunctions/asl/eval/test_state/program_state.py +22 -0
  113. localstack/services/stepfunctions/asl/jsonata/jsonata.py +5 -1
  114. localstack/services/stepfunctions/asl/parse/preprocessor.py +67 -24
  115. localstack/services/stepfunctions/asl/parse/test_state/asl_parser.py +5 -4
  116. localstack/services/stepfunctions/asl/parse/test_state/preprocessor.py +222 -31
  117. localstack/services/stepfunctions/asl/static_analyser/test_state/test_state_analyser.py +170 -22
  118. localstack/services/stepfunctions/backend/execution.py +6 -6
  119. localstack/services/stepfunctions/backend/execution_worker.py +5 -5
  120. localstack/services/stepfunctions/backend/test_state/execution.py +36 -0
  121. localstack/services/stepfunctions/backend/test_state/execution_worker.py +33 -1
  122. localstack/services/stepfunctions/backend/test_state/test_state_mock.py +127 -0
  123. localstack/services/stepfunctions/local_mocking/__init__.py +9 -0
  124. localstack/services/stepfunctions/{mocking → local_mocking}/mock_config.py +24 -17
  125. localstack/services/stepfunctions/provider.py +78 -27
  126. localstack/services/stepfunctions/test_state/mock_config.py +47 -0
  127. localstack/testing/pytest/fixtures.py +28 -0
  128. localstack/testing/snapshots/transformer_utility.py +7 -0
  129. localstack/testing/testselection/matching.py +0 -1
  130. localstack/utils/analytics/publisher.py +37 -155
  131. localstack/utils/analytics/service_request_aggregator.py +6 -4
  132. localstack/utils/aws/arns.py +7 -0
  133. localstack/utils/aws/client_types.py +0 -8
  134. localstack/utils/batching.py +258 -0
  135. localstack/utils/catalog/catalog_loader.py +111 -3
  136. localstack/utils/collections.py +23 -11
  137. localstack/utils/crypto.py +109 -0
  138. localstack/version.py +2 -2
  139. {localstack_core-4.10.1.dev7.dist-info → localstack_core-4.11.2.dev14.dist-info}/METADATA +7 -6
  140. {localstack_core-4.10.1.dev7.dist-info → localstack_core-4.11.2.dev14.dist-info}/RECORD +149 -141
  141. localstack_core-4.11.2.dev14.dist-info/plux.json +1 -0
  142. localstack/services/stepfunctions/mocking/__init__.py +0 -0
  143. localstack/utils/batch_policy.py +0 -124
  144. localstack_core-4.10.1.dev7.dist-info/plux.json +0 -1
  145. /localstack/services/stepfunctions/{mocking → local_mocking}/mock_config_file.py +0 -0
  146. {localstack_core-4.10.1.dev7.data → localstack_core-4.11.2.dev14.data}/scripts/localstack +0 -0
  147. {localstack_core-4.10.1.dev7.data → localstack_core-4.11.2.dev14.data}/scripts/localstack-supervisor +0 -0
  148. {localstack_core-4.10.1.dev7.data → localstack_core-4.11.2.dev14.data}/scripts/localstack.bat +0 -0
  149. {localstack_core-4.10.1.dev7.dist-info → localstack_core-4.11.2.dev14.dist-info}/WHEEL +0 -0
  150. {localstack_core-4.10.1.dev7.dist-info → localstack_core-4.11.2.dev14.dist-info}/entry_points.txt +0 -0
  151. {localstack_core-4.10.1.dev7.dist-info → localstack_core-4.11.2.dev14.dist-info}/licenses/LICENSE.txt +0 -0
  152. {localstack_core-4.10.1.dev7.dist-info → localstack_core-4.11.2.dev14.dist-info}/top_level.txt +0 -0
@@ -1,22 +1,30 @@
1
1
  import contextlib
2
2
  import copy
3
+ import functools
3
4
  import json
4
5
  import logging
5
6
  import re
6
7
 
7
8
  from botocore.utils import InvalidArnException
9
+ from rolo import Request, Router, route
8
10
 
9
11
  from localstack.aws.api import CommonServiceException, RequestContext
10
12
  from localstack.aws.api.sns import (
11
13
  AmazonResourceName,
14
+ BatchEntryIdsNotDistinctException,
12
15
  ConfirmSubscriptionResponse,
16
+ CreateEndpointResponse,
13
17
  CreatePlatformApplicationResponse,
14
18
  CreateTopicResponse,
19
+ Endpoint,
20
+ EndpointDisabledException,
21
+ GetEndpointAttributesResponse,
15
22
  GetPlatformApplicationAttributesResponse,
16
23
  GetSMSAttributesResponse,
17
24
  GetSubscriptionAttributesResponse,
18
25
  GetTopicAttributesResponse,
19
26
  InvalidParameterException,
27
+ InvalidParameterValueException,
20
28
  ListEndpointsByPlatformApplicationResponse,
21
29
  ListPlatformApplicationsResponse,
22
30
  ListString,
@@ -25,8 +33,14 @@ from localstack.aws.api.sns import (
25
33
  ListTagsForResourceResponse,
26
34
  ListTopicsResponse,
27
35
  MapStringToString,
36
+ MessageAttributeMap,
28
37
  NotFoundException,
38
+ PhoneNumber,
29
39
  PlatformApplication,
40
+ PublishBatchRequestEntryList,
41
+ PublishBatchResponse,
42
+ PublishBatchResultEntry,
43
+ PublishResponse,
30
44
  SetSMSAttributesResponse,
31
45
  SnsApi,
32
46
  String,
@@ -36,30 +50,55 @@ from localstack.aws.api.sns import (
36
50
  TagKeyList,
37
51
  TagList,
38
52
  TagResourceResponse,
53
+ TooManyEntriesInBatchRequestException,
39
54
  TopicAttributesMap,
40
55
  UntagResourceResponse,
41
56
  attributeName,
42
57
  attributeValue,
43
58
  authenticateOnUnsubscribe,
44
59
  endpoint,
60
+ message,
61
+ messageStructure,
45
62
  nextToken,
46
63
  protocol,
64
+ subject,
47
65
  subscriptionARN,
48
66
  topicARN,
49
67
  topicName,
50
68
  )
51
- from localstack.services.sns import constants as sns_constants
69
+ from localstack.constants import AWS_REGION_US_EAST_1, DEFAULT_AWS_ACCOUNT_ID
70
+ from localstack.http import Response
71
+ from localstack.services.edge import ROUTER
72
+ from localstack.services.plugins import ServiceLifecycleHook
73
+ from localstack.services.sns.analytics import internal_api_calls
52
74
  from localstack.services.sns.certificate import SNS_SERVER_CERT
53
75
  from localstack.services.sns.constants import (
76
+ ATTR_TYPE_REGEX,
54
77
  DUMMY_SUBSCRIPTION_PRINCIPAL,
78
+ MAXIMUM_MESSAGE_LENGTH,
79
+ MSG_ATTR_NAME_REGEX,
80
+ PLATFORM_ENDPOINT_MSGS_ENDPOINT,
81
+ SMS_MSGS_ENDPOINT,
82
+ SNS_CERT_ENDPOINT,
83
+ SNS_PROTOCOLS,
84
+ SUBSCRIPTION_TOKENS_ENDPOINT,
55
85
  VALID_APPLICATION_PLATFORMS,
86
+ VALID_MSG_ATTR_NAME_CHARS,
87
+ VALID_SUBSCRIPTION_ATTR_NAME,
56
88
  )
57
89
  from localstack.services.sns.filter import FilterPolicyValidator
58
- from localstack.services.sns.publisher import PublishDispatcher, SnsPublishContext
90
+ from localstack.services.sns.publisher import (
91
+ PublishDispatcher,
92
+ SnsBatchPublishContext,
93
+ SnsPublishContext,
94
+ )
59
95
  from localstack.services.sns.v2.models import (
60
96
  SMS_ATTRIBUTE_NAMES,
61
97
  SMS_DEFAULT_SENDER_REGEX,
62
98
  SMS_TYPES,
99
+ EndpointAttributeNames,
100
+ PlatformApplicationDetails,
101
+ PlatformEndpoint,
63
102
  SnsMessage,
64
103
  SnsMessageType,
65
104
  SnsStore,
@@ -68,22 +107,27 @@ from localstack.services.sns.v2.models import (
68
107
  sns_stores,
69
108
  )
70
109
  from localstack.services.sns.v2.utils import (
110
+ create_platform_endpoint_arn,
71
111
  create_subscription_arn,
72
112
  encode_subscription_token_with_region,
73
113
  get_next_page_token_from_arn,
74
114
  get_region_from_subscription_token,
115
+ get_topic_subscriptions,
75
116
  is_valid_e164_number,
76
117
  parse_and_validate_platform_application_arn,
77
118
  parse_and_validate_topic_arn,
78
119
  validate_subscription_attribute,
79
120
  )
80
121
  from localstack.utils.aws.arns import (
122
+ extract_account_id_from_arn,
123
+ extract_region_from_arn,
81
124
  get_partition,
82
125
  parse_arn,
83
126
  sns_platform_application_arn,
84
127
  sns_topic_arn,
85
128
  )
86
129
  from localstack.utils.collections import PaginatedList, select_from_typed_dict
130
+ from localstack.utils.strings import to_bytes
87
131
 
88
132
  # set up logger
89
133
  LOG = logging.getLogger(__name__)
@@ -92,12 +136,27 @@ SNS_TOPIC_NAME_PATTERN_FIFO = r"^[a-zA-Z0-9_-]{1,256}\.fifo$"
92
136
  SNS_TOPIC_NAME_PATTERN = r"^[a-zA-Z0-9_-]{1,256}$"
93
137
 
94
138
 
95
- class SnsProvider(SnsApi):
139
+ class SnsProvider(SnsApi, ServiceLifecycleHook):
96
140
  def __init__(self) -> None:
97
141
  super().__init__()
98
142
  self._publisher = PublishDispatcher()
99
143
  self._signature_cert_pem: str = SNS_SERVER_CERT
100
144
 
145
+ def on_before_stop(self):
146
+ self._publisher.shutdown()
147
+
148
+ def on_after_init(self):
149
+ # Allow sent platform endpoint messages to be retrieved from the SNS endpoint
150
+ register_sns_api_resource(ROUTER)
151
+ # add the route to serve the certificate used to validate message signatures
152
+ ROUTER.add(self.get_signature_cert_pem_file)
153
+
154
+ @route(SNS_CERT_ENDPOINT, methods=["GET"])
155
+ def get_signature_cert_pem_file(self, request: Request):
156
+ # see http://sns-public-resources.s3.amazonaws.com/SNS_Message_Signing_Release_Note_Jan_25_2011.pdf
157
+ # see https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message.html
158
+ return Response(self._signature_cert_pem, 200)
159
+
101
160
  ## Topic Operations
102
161
 
103
162
  def create_topic(
@@ -128,7 +187,6 @@ class SnsProvider(SnsApi):
128
187
  )
129
188
  return CreateTopicResponse(TopicArn=topic_arn)
130
189
 
131
- attributes = attributes or {}
132
190
  if attributes.get("FifoTopic") and attributes["FifoTopic"].lower() == "true":
133
191
  fifo_match = re.match(SNS_TOPIC_NAME_PATTERN_FIFO, name)
134
192
  if not fifo_match:
@@ -212,14 +270,12 @@ class SnsProvider(SnsApi):
212
270
 
213
271
  store = self.get_store(account_id=parsed_topic_arn["account"], region=context.region)
214
272
 
215
- if topic_arn not in store.topics:
216
- raise NotFoundException("Topic does not exist")
217
-
218
- topic_subscriptions = store.topics[topic_arn]["subscriptions"]
273
+ topic = self._get_topic(arn=topic_arn, context=context)
274
+ topic_subscriptions = topic["subscriptions"]
219
275
  if not endpoint:
220
276
  # TODO: check AWS behaviour (because endpoint is optional)
221
277
  raise NotFoundException("Endpoint not specified in subscription")
222
- if protocol not in sns_constants.SNS_PROTOCOLS:
278
+ if protocol not in SNS_PROTOCOLS:
223
279
  raise InvalidParameterException(
224
280
  f"Invalid parameter: Amazon SNS does not support this protocol string: {protocol}"
225
281
  )
@@ -237,10 +293,11 @@ class SnsProvider(SnsApi):
237
293
  raise InvalidParameterException("Invalid parameter: SQS endpoint ARN")
238
294
 
239
295
  elif protocol == "application":
240
- # TODO: This needs to be implemented once applications are ported from moto to the new provider
241
- raise NotImplementedError(
242
- "This functionality needs yet to be ported to the new SNS provider"
243
- )
296
+ # TODO: Validate exact behaviour
297
+ try:
298
+ parse_arn(endpoint)
299
+ except InvalidArnException:
300
+ raise InvalidParameterException("Invalid parameter: ApplicationEndpoint ARN")
244
301
 
245
302
  if ".fifo" in endpoint and ".fifo" not in topic_arn:
246
303
  # TODO: move to sqs protocol block if possible
@@ -268,7 +325,7 @@ class SnsProvider(SnsApi):
268
325
  if sub.get("Endpoint") == endpoint:
269
326
  if sub_attributes:
270
327
  # validate the subscription attributes aren't different
271
- for attr in sns_constants.VALID_SUBSCRIPTION_ATTR_NAME:
328
+ for attr in VALID_SUBSCRIPTION_ATTR_NAME:
272
329
  # if a new attribute is present and different from an existent one, raise
273
330
  if (new_attr := sub_attributes.get(attr)) and sub.get(attr) != new_attr:
274
331
  raise InvalidParameterException(
@@ -330,8 +387,7 @@ class SnsProvider(SnsApi):
330
387
  message=message_ctx,
331
388
  store=store,
332
389
  request_headers=context.request.headers,
333
- # TODO: add topic attributes once they are ported from moto to LocalStack
334
- # topic_attributes=vars(self._get_topic(topic_arn, context)),
390
+ topic_attributes=topic["attributes"],
335
391
  )
336
392
  self._publisher.publish_to_topic_subscriber(
337
393
  ctx=publish_ctx,
@@ -531,11 +587,10 @@ class SnsProvider(SnsApi):
531
587
  def list_subscriptions_by_topic(
532
588
  self, context: RequestContext, topic_arn: topicARN, next_token: nextToken = None, **kwargs
533
589
  ) -> ListSubscriptionsByTopicResponse:
534
- topic: Topic = self._get_topic(topic_arn, context)
590
+ self._get_topic(topic_arn, context) # for validation purposes only
535
591
  parsed_topic_arn = parse_and_validate_topic_arn(topic_arn)
536
592
  store = self.get_store(parsed_topic_arn["account"], parsed_topic_arn["region"])
537
- sub_arns: list[str] = topic.get("subscriptions", [])
538
- subscriptions = [store.subscriptions[k] for k in sub_arns if k in store.subscriptions]
593
+ subscriptions = get_topic_subscriptions(store, topic_arn)
539
594
 
540
595
  paginated_subscriptions = PaginatedList(subscriptions)
541
596
  page, next_token = paginated_subscriptions.get_page(
@@ -549,6 +604,238 @@ class SnsProvider(SnsApi):
549
604
  response["NextToken"] = next_token
550
605
  return response
551
606
 
607
+ #
608
+ # Publish
609
+ #
610
+
611
+ def publish(
612
+ self,
613
+ context: RequestContext,
614
+ message: message,
615
+ topic_arn: topicARN | None = None,
616
+ target_arn: String | None = None,
617
+ phone_number: PhoneNumber | None = None,
618
+ subject: subject | None = None,
619
+ message_structure: messageStructure | None = None,
620
+ message_attributes: MessageAttributeMap | None = None,
621
+ message_deduplication_id: String | None = None,
622
+ message_group_id: String | None = None,
623
+ **kwargs,
624
+ ) -> PublishResponse:
625
+ if subject == "":
626
+ raise InvalidParameterException("Invalid parameter: Subject")
627
+ if not message or all(not m for m in message):
628
+ raise InvalidParameterException("Invalid parameter: Empty message")
629
+
630
+ # TODO: check for topic + target + phone number at the same time?
631
+ # TODO: more validation on phone, it might be opted out?
632
+ if phone_number and not is_valid_e164_number(phone_number):
633
+ raise InvalidParameterException(
634
+ f"Invalid parameter: PhoneNumber Reason: {phone_number} is not valid to publish to"
635
+ )
636
+
637
+ if message_attributes:
638
+ _validate_message_attributes(message_attributes)
639
+
640
+ if _get_total_publish_size(message, message_attributes) > MAXIMUM_MESSAGE_LENGTH:
641
+ raise InvalidParameterException("Invalid parameter: Message too long")
642
+
643
+ # for compatibility reasons, AWS allows users to use either TargetArn or TopicArn for publishing to a topic
644
+ # use any of them for topic validation
645
+ topic_or_target_arn = topic_arn or target_arn
646
+ topic = None
647
+
648
+ if is_fifo := (topic_or_target_arn and ".fifo" in topic_or_target_arn):
649
+ if not message_group_id:
650
+ raise InvalidParameterException(
651
+ "Invalid parameter: The MessageGroupId parameter is required for FIFO topics",
652
+ )
653
+ topic = self._get_topic(topic_or_target_arn, context)
654
+ if topic["attributes"]["ContentBasedDeduplication"] == "false":
655
+ if not message_deduplication_id:
656
+ raise InvalidParameterException(
657
+ "Invalid parameter: The topic should either have ContentBasedDeduplication enabled or MessageDeduplicationId provided explicitly",
658
+ )
659
+ elif message_deduplication_id:
660
+ # this is the first one to raise if both are set while the topic is not fifo
661
+ raise InvalidParameterException(
662
+ "Invalid parameter: MessageDeduplicationId Reason: The request includes MessageDeduplicationId parameter that is not valid for this topic type"
663
+ )
664
+
665
+ is_endpoint_publish = target_arn and ":endpoint/" in target_arn
666
+ if message_structure == "json":
667
+ try:
668
+ message = json.loads(message)
669
+ # Keys in the JSON object that correspond to supported transport protocols must have
670
+ # simple JSON string values.
671
+ # Non-string values will cause the key to be ignored.
672
+ message = {key: field for key, field in message.items() if isinstance(field, str)}
673
+ # TODO: check no default key for direct TargetArn endpoint publish, need credentials
674
+ # see example: https://docs.aws.amazon.com/sns/latest/dg/sns-send-custom-platform-specific-payloads-mobile-devices.html
675
+ if "default" not in message and not is_endpoint_publish:
676
+ raise InvalidParameterException(
677
+ "Invalid parameter: Message Structure - No default entry in JSON message body"
678
+ )
679
+ except json.JSONDecodeError:
680
+ raise InvalidParameterException(
681
+ "Invalid parameter: Message Structure - JSON message body failed to parse"
682
+ )
683
+
684
+ if not phone_number:
685
+ # use the account to get the store from the TopicArn (you can only publish in the same region as the topic)
686
+ parsed_arn = parse_and_validate_topic_arn(topic_or_target_arn)
687
+ store = self.get_store(account_id=parsed_arn["account"], region=context.region)
688
+ if is_endpoint_publish:
689
+ if not (platform_endpoint := store.platform_endpoints.get(target_arn)):
690
+ raise InvalidParameterException(
691
+ "Invalid parameter: TargetArn Reason: No endpoint found for the target arn specified"
692
+ )
693
+ elif (
694
+ not platform_endpoint.platform_endpoint["Attributes"]
695
+ .get("Enabled", "false")
696
+ .lower()
697
+ == "true"
698
+ ):
699
+ raise EndpointDisabledException("Endpoint is disabled")
700
+ else:
701
+ topic = self._get_topic(topic_or_target_arn, context)
702
+ else:
703
+ # use the store from the request context
704
+ store = self.get_store(account_id=context.account_id, region=context.region)
705
+
706
+ message_ctx = SnsMessage(
707
+ type=SnsMessageType.Notification,
708
+ message=message,
709
+ message_attributes=message_attributes,
710
+ message_deduplication_id=message_deduplication_id,
711
+ message_group_id=message_group_id,
712
+ message_structure=message_structure,
713
+ subject=subject,
714
+ is_fifo=is_fifo,
715
+ )
716
+ publish_ctx = SnsPublishContext(
717
+ message=message_ctx, store=store, request_headers=context.request.headers
718
+ )
719
+
720
+ if is_endpoint_publish:
721
+ self._publisher.publish_to_application_endpoint(
722
+ ctx=publish_ctx, endpoint_arn=target_arn
723
+ )
724
+ elif phone_number:
725
+ self._publisher.publish_to_phone_number(ctx=publish_ctx, phone_number=phone_number)
726
+ else:
727
+ # beware if the subscription is FIFO, the order might not be guaranteed.
728
+ # 2 quick call to this method in succession might not be executed in order in the executor?
729
+ # TODO: test how this behaves in a FIFO context with a lot of threads.
730
+ publish_ctx.topic_attributes |= topic["attributes"]
731
+ self._publisher.publish_to_topic(publish_ctx, topic_or_target_arn)
732
+
733
+ if is_fifo:
734
+ return PublishResponse(
735
+ MessageId=message_ctx.message_id, SequenceNumber=message_ctx.sequencer_number
736
+ )
737
+
738
+ return PublishResponse(MessageId=message_ctx.message_id)
739
+
740
+ def publish_batch(
741
+ self,
742
+ context: RequestContext,
743
+ topic_arn: topicARN,
744
+ publish_batch_request_entries: PublishBatchRequestEntryList,
745
+ **kwargs,
746
+ ) -> PublishBatchResponse:
747
+ if len(publish_batch_request_entries) > 10:
748
+ raise TooManyEntriesInBatchRequestException(
749
+ "The batch request contains more entries than permissible."
750
+ )
751
+
752
+ parsed_arn = parse_and_validate_topic_arn(topic_arn)
753
+ store = self.get_store(account_id=parsed_arn["account"], region=context.region)
754
+ topic = self._get_topic(topic_arn, context)
755
+ ids = [entry["Id"] for entry in publish_batch_request_entries]
756
+ if len(set(ids)) != len(publish_batch_request_entries):
757
+ raise BatchEntryIdsNotDistinctException(
758
+ "Two or more batch entries in the request have the same Id."
759
+ )
760
+
761
+ response: PublishBatchResponse = {"Successful": [], "Failed": []}
762
+
763
+ # TODO: write AWS validated tests with FilterPolicy and batching
764
+ # TODO: find a scenario where we can fail to send a message synchronously to be able to report it
765
+ # right now, it seems that AWS fails the whole publish if something is wrong in the format of 1 message
766
+
767
+ total_batch_size = 0
768
+ message_contexts = []
769
+ for entry_index, entry in enumerate(publish_batch_request_entries, start=1):
770
+ message_payload = entry.get("Message")
771
+ message_attributes = entry.get("MessageAttributes", {})
772
+ if message_attributes:
773
+ # if a message contains non-valid message attributes, it
774
+ # will fail for the first non-valid message encountered, and raise ParameterValueInvalid
775
+ _validate_message_attributes(message_attributes, position=entry_index)
776
+
777
+ total_batch_size += _get_total_publish_size(message_payload, message_attributes)
778
+
779
+ # TODO: WRITE AWS VALIDATED
780
+ if entry.get("MessageStructure") == "json":
781
+ try:
782
+ message = json.loads(message_payload)
783
+ # Keys in the JSON object that correspond to supported transport protocols must have
784
+ # simple JSON string values.
785
+ # Non-string values will cause the key to be ignored.
786
+ message = {
787
+ key: field for key, field in message.items() if isinstance(field, str)
788
+ }
789
+ if "default" not in message:
790
+ raise InvalidParameterException(
791
+ "Invalid parameter: Message Structure - No default entry in JSON message body"
792
+ )
793
+ entry["Message"] = message # noqa
794
+ except json.JSONDecodeError:
795
+ raise InvalidParameterException(
796
+ "Invalid parameter: Message Structure - JSON message body failed to parse"
797
+ )
798
+
799
+ if is_fifo := (topic_arn.endswith(".fifo")):
800
+ if not all("MessageGroupId" in entry for entry in publish_batch_request_entries):
801
+ raise InvalidParameterException(
802
+ "Invalid parameter: The MessageGroupId parameter is required for FIFO topics"
803
+ )
804
+ if topic["attributes"]["ContentBasedDeduplication"] == "false":
805
+ if not all(
806
+ "MessageDeduplicationId" in entry for entry in publish_batch_request_entries
807
+ ):
808
+ raise InvalidParameterException(
809
+ "Invalid parameter: The topic should either have ContentBasedDeduplication enabled or MessageDeduplicationId provided explicitly",
810
+ )
811
+
812
+ msg_ctx = SnsMessage.from_batch_entry(entry, is_fifo=is_fifo)
813
+ message_contexts.append(msg_ctx)
814
+ success = PublishBatchResultEntry(
815
+ Id=entry["Id"],
816
+ MessageId=msg_ctx.message_id,
817
+ )
818
+ if is_fifo:
819
+ success["SequenceNumber"] = msg_ctx.sequencer_number
820
+ response["Successful"].append(success)
821
+
822
+ if total_batch_size > MAXIMUM_MESSAGE_LENGTH:
823
+ raise CommonServiceException(
824
+ code="BatchRequestTooLong",
825
+ message="The length of all the messages put together is more than the limit.",
826
+ sender_fault=True,
827
+ )
828
+
829
+ publish_ctx = SnsBatchPublishContext(
830
+ messages=message_contexts,
831
+ store=store,
832
+ request_headers=context.request.headers,
833
+ topic_attributes=topic["attributes"],
834
+ )
835
+ self._publisher.publish_batch_to_topic(publish_ctx, topic_arn)
836
+
837
+ return response
838
+
552
839
  #
553
840
  # PlatformApplications
554
841
  #
@@ -591,17 +878,24 @@ class SnsProvider(SnsApi):
591
878
  account_id=context.account_id,
592
879
  region_name=context.region,
593
880
  )
594
- platform_application = PlatformApplication(
595
- PlatformApplicationArn=application_arn, Attributes=_attributes
881
+ platform_application_details = PlatformApplicationDetails(
882
+ platform_application=PlatformApplication(
883
+ PlatformApplicationArn=application_arn,
884
+ Attributes=_attributes,
885
+ ),
886
+ platform_endpoints={},
596
887
  )
597
- store.platform_applications[application_arn] = platform_application
598
- return CreatePlatformApplicationResponse(**platform_application)
888
+ store.platform_applications[application_arn] = platform_application_details
889
+
890
+ return platform_application_details.platform_application
599
891
 
600
892
  def delete_platform_application(
601
893
  self, context: RequestContext, platform_application_arn: String, **kwargs
602
894
  ) -> None:
603
895
  store = self.get_store(context.account_id, context.region)
604
896
  store.platform_applications.pop(platform_application_arn, None)
897
+ # TODO: if the platform had endpoints, should we remove them from the store? There is no way to list
898
+ # endpoints without an application, so this is impossible to check the state of AWS here
605
899
 
606
900
  def list_platform_applications(
607
901
  self, context: RequestContext, next_token: String | None = None, **kwargs
@@ -615,7 +909,9 @@ class SnsProvider(SnsApi):
615
909
  next_token=next_token,
616
910
  )
617
911
 
618
- response = ListPlatformApplicationsResponse(PlatformApplications=page)
912
+ response = ListPlatformApplicationsResponse(
913
+ PlatformApplications=[platform_app.platform_application for platform_app in page]
914
+ )
619
915
  if token:
620
916
  response["NextToken"] = token
621
917
  return response
@@ -644,6 +940,62 @@ class SnsProvider(SnsApi):
644
940
  # Platform Endpoints
645
941
  #
646
942
 
943
+ def create_platform_endpoint(
944
+ self,
945
+ context: RequestContext,
946
+ platform_application_arn: String,
947
+ token: String,
948
+ custom_user_data: String | None = None,
949
+ attributes: MapStringToString | None = None,
950
+ **kwargs,
951
+ ) -> CreateEndpointResponse:
952
+ store = self.get_store(context.account_id, context.region)
953
+ application = store.platform_applications.get(platform_application_arn)
954
+ if not application:
955
+ raise NotFoundException("PlatformApplication does not exist")
956
+ endpoint_arn = application.platform_endpoints.get(token, {})
957
+ attributes = attributes or {}
958
+ _validate_endpoint_attributes(attributes, allow_empty=True)
959
+ # CustomUserData can be specified both in attributes and as parameter. Attributes take precedence
960
+ attributes.setdefault(EndpointAttributeNames.CUSTOM_USER_DATA, custom_user_data)
961
+ _attributes = {"Enabled": "true", "Token": token, **attributes}
962
+ if endpoint_arn and (
963
+ platform_endpoint_details := store.platform_endpoints.get(endpoint_arn)
964
+ ):
965
+ # endpoint for that application with that particular token already exists
966
+ if not platform_endpoint_details.platform_endpoint["Attributes"] == _attributes:
967
+ raise InvalidParameterException(
968
+ f"Invalid parameter: Token Reason: Endpoint {endpoint_arn} already exists with the same Token, but different attributes."
969
+ )
970
+ else:
971
+ return CreateEndpointResponse(EndpointArn=endpoint_arn)
972
+
973
+ endpoint_arn = create_platform_endpoint_arn(platform_application_arn)
974
+ platform_endpoint = PlatformEndpoint(
975
+ platform_application_arn=endpoint_arn,
976
+ platform_endpoint=Endpoint(
977
+ Attributes=_attributes,
978
+ EndpointArn=endpoint_arn,
979
+ ),
980
+ )
981
+ store.platform_endpoints[endpoint_arn] = platform_endpoint
982
+ application.platform_endpoints[token] = endpoint_arn
983
+
984
+ return CreateEndpointResponse(EndpointArn=endpoint_arn)
985
+
986
+ def delete_endpoint(self, context: RequestContext, endpoint_arn: String, **kwargs) -> None:
987
+ store = self.get_store(context.account_id, context.region)
988
+ platform_endpoint_details = store.platform_endpoints.pop(endpoint_arn, None)
989
+ if platform_endpoint_details:
990
+ platform_application = store.platform_applications.get(
991
+ platform_endpoint_details.platform_application_arn
992
+ )
993
+ if platform_application:
994
+ platform_endpoint = platform_endpoint_details.platform_endpoint
995
+ platform_application.platform_endpoints.pop(
996
+ platform_endpoint["Attributes"]["Token"], None
997
+ )
998
+
647
999
  def list_endpoints_by_platform_application(
648
1000
  self,
649
1001
  context: RequestContext,
@@ -651,8 +1003,49 @@ class SnsProvider(SnsApi):
651
1003
  next_token: String | None = None,
652
1004
  **kwargs,
653
1005
  ) -> ListEndpointsByPlatformApplicationResponse:
654
- # TODO: stub so cleanup fixture won't fail
655
- return ListEndpointsByPlatformApplicationResponse(Endpoints=[])
1006
+ store = self.get_store(context.account_id, context.region)
1007
+ platform_application = store.platform_applications.get(platform_application_arn)
1008
+ if not platform_application:
1009
+ raise NotFoundException("PlatformApplication does not exist")
1010
+ endpoint_arns = platform_application.platform_endpoints.values()
1011
+ paginated_endpoint_arns = PaginatedList(endpoint_arns)
1012
+ page, token = paginated_endpoint_arns.get_page(
1013
+ token_generator=lambda x: get_next_page_token_from_arn(x),
1014
+ page_size=100,
1015
+ next_token=next_token,
1016
+ )
1017
+
1018
+ response = ListEndpointsByPlatformApplicationResponse(
1019
+ Endpoints=[
1020
+ store.platform_endpoints[endpoint_arn].platform_endpoint
1021
+ for endpoint_arn in page
1022
+ if endpoint_arn in store.platform_endpoints
1023
+ ]
1024
+ )
1025
+ if token:
1026
+ response["NextToken"] = token
1027
+ return response
1028
+
1029
+ def get_endpoint_attributes(
1030
+ self, context: RequestContext, endpoint_arn: String, **kwargs
1031
+ ) -> GetEndpointAttributesResponse:
1032
+ store = self.get_store(context.account_id, context.region)
1033
+ platform_endpoint_details = store.platform_endpoints.get(endpoint_arn)
1034
+ if not platform_endpoint_details:
1035
+ raise NotFoundException("Endpoint does not exist")
1036
+ attributes = platform_endpoint_details.platform_endpoint["Attributes"]
1037
+ return GetEndpointAttributesResponse(Attributes=attributes)
1038
+
1039
+ def set_endpoint_attributes(
1040
+ self, context: RequestContext, endpoint_arn: String, attributes: MapStringToString, **kwargs
1041
+ ) -> None:
1042
+ store = self.get_store(context.account_id, context.region)
1043
+ platform_endpoint_details = store.platform_endpoints.get(endpoint_arn)
1044
+ if not platform_endpoint_details:
1045
+ raise NotFoundException("Endpoint does not exist")
1046
+ _validate_endpoint_attributes(attributes)
1047
+ attributes = attributes or {}
1048
+ platform_endpoint_details.platform_endpoint["Attributes"].update(attributes)
656
1049
 
657
1050
  #
658
1051
  # Sms operations
@@ -712,16 +1105,15 @@ class SnsProvider(SnsApi):
712
1105
  def get_store(account_id: str, region: str) -> SnsStore:
713
1106
  return sns_stores[account_id][region]
714
1107
 
715
- # TODO: reintroduce multi-region parameter (latest before final migration from v1)
716
1108
  @staticmethod
717
- def _get_topic(arn: str, context: RequestContext) -> Topic:
1109
+ def _get_topic(arn: str, context: RequestContext, multi_region: bool = False) -> Topic:
718
1110
  """
719
1111
  :param arn: the Topic ARN
720
1112
  :param context: the RequestContext of the request
721
1113
  :return: the model Topic
722
1114
  """
723
1115
  arn_data = parse_and_validate_topic_arn(arn)
724
- if context.region != arn_data["region"]:
1116
+ if not multi_region and context.region != arn_data["region"]:
725
1117
  raise InvalidParameterException("Invalid parameter: TopicArn")
726
1118
  try:
727
1119
  store = SnsProvider.get_store(context.account_id, context.region)
@@ -736,7 +1128,7 @@ class SnsProvider(SnsApi):
736
1128
  parse_and_validate_platform_application_arn(platform_application_arn)
737
1129
  try:
738
1130
  store = SnsProvider.get_store(context.account_id, context.region)
739
- return store.platform_applications[platform_application_arn]
1131
+ return store.platform_applications[platform_application_arn].platform_application
740
1132
  except KeyError:
741
1133
  raise NotFoundException("PlatformApplication does not exist")
742
1134
 
@@ -773,7 +1165,6 @@ def _default_attributes(topic: Topic, context: RequestContext) -> TopicAttribute
773
1165
  {
774
1166
  "ContentBasedDeduplication": "false",
775
1167
  "FifoTopic": "false",
776
- "SignatureVersion": "2",
777
1168
  }
778
1169
  )
779
1170
  return default_attributes
@@ -807,6 +1198,94 @@ def _create_default_topic_policy(topic: Topic, context: RequestContext) -> str:
807
1198
  )
808
1199
 
809
1200
 
1201
+ def _validate_message_attributes(
1202
+ message_attributes: MessageAttributeMap, position: int | None = None
1203
+ ) -> None:
1204
+ """
1205
+ Validate the message attributes, and raises an exception if those do not follow AWS validation
1206
+ See: https://docs.aws.amazon.com/sns/latest/dg/sns-message-attributes.html
1207
+ Regex from: https://stackoverflow.com/questions/40718851/regex-that-does-not-allow-consecutive-dots
1208
+ :param message_attributes: the message attributes map for the message
1209
+ :param position: given to give the Batch Entry position if coming from `publishBatch`
1210
+ :raises: InvalidParameterValueException
1211
+ :return: None
1212
+ """
1213
+ for attr_name, attr in message_attributes.items():
1214
+ if len(attr_name) > 256:
1215
+ raise InvalidParameterValueException(
1216
+ "Length of message attribute name must be less than 256 bytes."
1217
+ )
1218
+ _validate_message_attribute_name(attr_name)
1219
+ # `DataType` is a required field for MessageAttributeValue
1220
+ if (data_type := attr.get("DataType")) is None:
1221
+ if position:
1222
+ at = f"publishBatchRequestEntries.{position}.member.messageAttributes.{attr_name}.member.dataType"
1223
+ else:
1224
+ at = f"messageAttributes.{attr_name}.member.dataType"
1225
+
1226
+ raise CommonServiceException(
1227
+ code="ValidationError",
1228
+ message=f"1 validation error detected: Value null at '{at}' failed to satisfy constraint: Member must not be null",
1229
+ sender_fault=True,
1230
+ )
1231
+
1232
+ if data_type not in (
1233
+ "String",
1234
+ "Number",
1235
+ "Binary",
1236
+ ) and not ATTR_TYPE_REGEX.match(data_type):
1237
+ raise InvalidParameterValueException(
1238
+ f"The message attribute '{attr_name}' has an invalid message attribute type, the set of supported type prefixes is Binary, Number, and String."
1239
+ )
1240
+ if not any(attr_value.endswith("Value") for attr_value in attr):
1241
+ raise InvalidParameterValueException(
1242
+ f"The message attribute '{attr_name}' must contain non-empty message attribute value for message attribute type '{data_type}'."
1243
+ )
1244
+
1245
+ value_key_data_type = "Binary" if data_type.startswith("Binary") else "String"
1246
+ value_key = f"{value_key_data_type}Value"
1247
+ if value_key not in attr:
1248
+ raise InvalidParameterValueException(
1249
+ f"The message attribute '{attr_name}' with type '{data_type}' must use field '{value_key_data_type}'."
1250
+ )
1251
+ elif not attr[value_key]:
1252
+ raise InvalidParameterValueException(
1253
+ f"The message attribute '{attr_name}' must contain non-empty message attribute value for message attribute type '{data_type}'.",
1254
+ )
1255
+
1256
+
1257
+ def _validate_message_attribute_name(name: str) -> None:
1258
+ """
1259
+ Validate the message attribute name with the specification of AWS.
1260
+ The message attribute name can contain the following characters: A-Z, a-z, 0-9, underscore(_), hyphen(-), and period (.). The name must not start or end with a period, and it should not have successive periods.
1261
+ :param name: message attribute name
1262
+ :raises InvalidParameterValueException: if the name does not conform to the spec
1263
+ """
1264
+ if not MSG_ATTR_NAME_REGEX.match(name):
1265
+ # find the proper exception
1266
+ if name[0] == ".":
1267
+ raise InvalidParameterValueException(
1268
+ "Invalid message attribute name starting with character '.' was found."
1269
+ )
1270
+ elif name[-1] == ".":
1271
+ raise InvalidParameterValueException(
1272
+ "Invalid message attribute name ending with character '.' was found."
1273
+ )
1274
+
1275
+ for idx, char in enumerate(name):
1276
+ if char not in VALID_MSG_ATTR_NAME_CHARS:
1277
+ # change prefix from 0x to #x, without capitalizing the x
1278
+ hex_char = "#x" + hex(ord(char)).upper()[2:]
1279
+ raise InvalidParameterValueException(
1280
+ f"Invalid non-alphanumeric character '{hex_char}' was found in the message attribute name. Can only include alphanumeric characters, hyphens, underscores, or dots."
1281
+ )
1282
+ # even if we go negative index, it will be covered by starting/ending with dot
1283
+ if char == "." and name[idx - 1] == ".":
1284
+ raise InvalidParameterValueException(
1285
+ "Message attribute name can not have successive '.' character."
1286
+ )
1287
+
1288
+
810
1289
  def _validate_platform_application_name(name: str) -> None:
811
1290
  reason = ""
812
1291
  if not name:
@@ -821,6 +1300,10 @@ def _validate_platform_application_name(name: str) -> None:
821
1300
 
822
1301
 
823
1302
  def _validate_platform_application_attributes(attributes: dict) -> None:
1303
+ _check_empty_attributes(attributes)
1304
+
1305
+
1306
+ def _check_empty_attributes(attributes: dict) -> None:
824
1307
  if not attributes:
825
1308
  raise CommonServiceException(
826
1309
  code="ValidationError",
@@ -829,6 +1312,20 @@ def _validate_platform_application_attributes(attributes: dict) -> None:
829
1312
  )
830
1313
 
831
1314
 
1315
+ def _validate_endpoint_attributes(attributes: dict, allow_empty: bool = False) -> None:
1316
+ if not allow_empty:
1317
+ _check_empty_attributes(attributes)
1318
+ for key in attributes:
1319
+ if key not in EndpointAttributeNames:
1320
+ raise InvalidParameterException(
1321
+ f"Invalid parameter: Attributes Reason: Invalid attribute name: {key}"
1322
+ )
1323
+ if len(attributes.get(EndpointAttributeNames.CUSTOM_USER_DATA, "")) > 2048:
1324
+ raise InvalidParameterException(
1325
+ "Invalid parameter: Attributes Reason: Invalid value for attribute: CustomUserData: must be at most 2048 bytes long in UTF-8 encoding"
1326
+ )
1327
+
1328
+
832
1329
  def _validate_sms_attributes(attributes: dict) -> None:
833
1330
  for k, v in attributes.items():
834
1331
  if k not in SMS_ATTRIBUTE_NAMES:
@@ -865,3 +1362,269 @@ def _check_matching_tags(topic_arn: str, tags: TagList | None, store: SnsStore)
865
1362
  if existing_tags is not None and tag not in existing_tags:
866
1363
  return False
867
1364
  return True
1365
+
1366
+
1367
+ def _get_total_publish_size(
1368
+ message_body: str, message_attributes: MessageAttributeMap | None
1369
+ ) -> int:
1370
+ size = _get_byte_size(message_body)
1371
+ if message_attributes:
1372
+ # https://docs.aws.amazon.com/sns/latest/dg/sns-message-attributes.html
1373
+ # All parts of the message attribute, including name, type, and value, are included in the message size
1374
+ # restriction, which is 256 KB.
1375
+ # iterate over the Keys and Attributes, adding the length of the Key to the length of all Attributes values
1376
+ # (DataType and StringValue or BinaryValue)
1377
+ size += sum(
1378
+ _get_byte_size(key) + sum(_get_byte_size(attr_value) for attr_value in attr.values())
1379
+ for key, attr in message_attributes.items()
1380
+ )
1381
+
1382
+ return size
1383
+
1384
+
1385
+ def _get_byte_size(payload: str | bytes) -> int:
1386
+ # Calculate the real length of the byte object if the object is a string
1387
+ return len(to_bytes(payload))
1388
+
1389
+
1390
+ def _register_sns_api_resource(router: Router):
1391
+ """Register the retrospection endpoints as internal LocalStack endpoints."""
1392
+ router.add(SNSServicePlatformEndpointMessagesApiResource())
1393
+ router.add(SNSServiceSMSMessagesApiResource())
1394
+ router.add(SNSServiceSubscriptionTokenApiResource())
1395
+
1396
+
1397
+ class SNSInternalResource:
1398
+ resource_type: str
1399
+ """Base class with helper to properly track usage of internal endpoints"""
1400
+
1401
+ def count_usage(self):
1402
+ internal_api_calls.labels(resource_type=self.resource_type).increment()
1403
+
1404
+
1405
+ def count_usage(f):
1406
+ @functools.wraps(f)
1407
+ def _wrapper(self, *args, **kwargs):
1408
+ self.count_usage()
1409
+ return f(self, *args, **kwargs)
1410
+
1411
+ return _wrapper
1412
+
1413
+
1414
+ class SNSServicePlatformEndpointMessagesApiResource(SNSInternalResource):
1415
+ resource_type = "platform-endpoint-message"
1416
+ """Provides a REST API for retrospective access to platform endpoint messages sent via SNS.
1417
+
1418
+ This is registered as a LocalStack internal HTTP resource.
1419
+
1420
+ This endpoint accepts:
1421
+ - GET param `accountId`: selector for AWS account. If not specified, return fallback `000000000000` test ID
1422
+ - GET param `region`: selector for AWS `region`. If not specified, return default "us-east-1"
1423
+ - GET param `endpointArn`: filter for `endpointArn` resource in SNS
1424
+ - DELETE param `accountId`: selector for AWS account
1425
+ - DELETE param `region`: will delete saved messages for `region`
1426
+ - DELETE param `endpointArn`: will delete saved messages for `endpointArn`
1427
+ """
1428
+
1429
+ _PAYLOAD_FIELDS = [
1430
+ "TargetArn",
1431
+ "TopicArn",
1432
+ "Message",
1433
+ "MessageAttributes",
1434
+ "MessageStructure",
1435
+ "Subject",
1436
+ "MessageId",
1437
+ ]
1438
+
1439
+ @route(PLATFORM_ENDPOINT_MSGS_ENDPOINT, methods=["GET"])
1440
+ @count_usage
1441
+ def on_get(self, request: Request):
1442
+ filter_endpoint_arn = request.args.get("endpointArn")
1443
+ account_id = (
1444
+ request.args.get("accountId", DEFAULT_AWS_ACCOUNT_ID)
1445
+ if not filter_endpoint_arn
1446
+ else extract_account_id_from_arn(filter_endpoint_arn)
1447
+ )
1448
+ region = (
1449
+ request.args.get("region", AWS_REGION_US_EAST_1)
1450
+ if not filter_endpoint_arn
1451
+ else extract_region_from_arn(filter_endpoint_arn)
1452
+ )
1453
+ store: SnsStore = sns_stores[account_id][region]
1454
+ if filter_endpoint_arn:
1455
+ messages = store.platform_endpoint_messages.get(filter_endpoint_arn, [])
1456
+ messages = _format_messages(messages, self._PAYLOAD_FIELDS)
1457
+ return {
1458
+ "platform_endpoint_messages": {filter_endpoint_arn: messages},
1459
+ "region": region,
1460
+ }
1461
+
1462
+ platform_endpoint_messages = {
1463
+ endpoint_arn: _format_messages(messages, self._PAYLOAD_FIELDS)
1464
+ for endpoint_arn, messages in store.platform_endpoint_messages.items()
1465
+ }
1466
+ return {
1467
+ "platform_endpoint_messages": platform_endpoint_messages,
1468
+ "region": region,
1469
+ }
1470
+
1471
+ @route(PLATFORM_ENDPOINT_MSGS_ENDPOINT, methods=["DELETE"])
1472
+ @count_usage
1473
+ def on_delete(self, request: Request) -> Response:
1474
+ filter_endpoint_arn = request.args.get("endpointArn")
1475
+ account_id = (
1476
+ request.args.get("accountId", DEFAULT_AWS_ACCOUNT_ID)
1477
+ if not filter_endpoint_arn
1478
+ else extract_account_id_from_arn(filter_endpoint_arn)
1479
+ )
1480
+ region = (
1481
+ request.args.get("region", AWS_REGION_US_EAST_1)
1482
+ if not filter_endpoint_arn
1483
+ else extract_region_from_arn(filter_endpoint_arn)
1484
+ )
1485
+ store: SnsStore = sns_stores[account_id][region]
1486
+ if filter_endpoint_arn:
1487
+ store.platform_endpoint_messages.pop(filter_endpoint_arn, None)
1488
+ return Response("", status=204)
1489
+
1490
+ store.platform_endpoint_messages.clear()
1491
+ return Response("", status=204)
1492
+
1493
+
1494
+ def register_sns_api_resource(router: Router):
1495
+ """Register the retrospection endpoints as internal LocalStack endpoints."""
1496
+ router.add(SNSServicePlatformEndpointMessagesApiResource())
1497
+ router.add(SNSServiceSMSMessagesApiResource())
1498
+ router.add(SNSServiceSubscriptionTokenApiResource())
1499
+
1500
+
1501
+ def _format_messages(sent_messages: list[dict[str, str]], validated_keys: list[str]):
1502
+ """
1503
+ This method format the messages to be more readable and undo the format change that was needed for Moto
1504
+ Should be removed once we refactor SNS.
1505
+ """
1506
+ formatted_messages = []
1507
+ for sent_message in sent_messages:
1508
+ msg = {
1509
+ key: json.dumps(value)
1510
+ if key == "Message" and sent_message.get("MessageStructure") == "json"
1511
+ else value
1512
+ for key, value in sent_message.items()
1513
+ if key in validated_keys
1514
+ }
1515
+ formatted_messages.append(msg)
1516
+
1517
+ return formatted_messages
1518
+
1519
+
1520
+ class SNSServiceSMSMessagesApiResource(SNSInternalResource):
1521
+ resource_type = "sms-message"
1522
+ """Provides a REST API for retrospective access to SMS messages sent via SNS.
1523
+
1524
+ This is registered as a LocalStack internal HTTP resource.
1525
+
1526
+ This endpoint accepts:
1527
+ - GET param `accountId`: selector for AWS account. If not specified, return fallback `000000000000` test ID
1528
+ - GET param `region`: selector for AWS `region`. If not specified, return default "us-east-1"
1529
+ - GET param `phoneNumber`: filter for `phoneNumber` resource in SNS
1530
+ """
1531
+
1532
+ _PAYLOAD_FIELDS = [
1533
+ "PhoneNumber",
1534
+ "TopicArn",
1535
+ "SubscriptionArn",
1536
+ "MessageId",
1537
+ "Message",
1538
+ "MessageAttributes",
1539
+ "MessageStructure",
1540
+ "Subject",
1541
+ ]
1542
+
1543
+ @route(SMS_MSGS_ENDPOINT, methods=["GET"])
1544
+ @count_usage
1545
+ def on_get(self, request: Request):
1546
+ account_id = request.args.get("accountId", DEFAULT_AWS_ACCOUNT_ID)
1547
+ region = request.args.get("region", AWS_REGION_US_EAST_1)
1548
+ filter_phone_number = request.args.get("phoneNumber")
1549
+ store: SnsStore = sns_stores[account_id][region]
1550
+ if filter_phone_number:
1551
+ messages = [
1552
+ m for m in store.sms_messages if m.get("PhoneNumber") == filter_phone_number
1553
+ ]
1554
+ messages = _format_messages(messages, self._PAYLOAD_FIELDS)
1555
+ return {
1556
+ "sms_messages": {filter_phone_number: messages},
1557
+ "region": region,
1558
+ }
1559
+
1560
+ sms_messages = {}
1561
+
1562
+ for m in _format_messages(store.sms_messages, self._PAYLOAD_FIELDS):
1563
+ sms_messages.setdefault(m.get("PhoneNumber"), []).append(m)
1564
+
1565
+ return {
1566
+ "sms_messages": sms_messages,
1567
+ "region": region,
1568
+ }
1569
+
1570
+ @route(SMS_MSGS_ENDPOINT, methods=["DELETE"])
1571
+ @count_usage
1572
+ def on_delete(self, request: Request) -> Response:
1573
+ account_id = request.args.get("accountId", DEFAULT_AWS_ACCOUNT_ID)
1574
+ region = request.args.get("region", AWS_REGION_US_EAST_1)
1575
+ filter_phone_number = request.args.get("phoneNumber")
1576
+ store: SnsStore = sns_stores[account_id][region]
1577
+ if filter_phone_number:
1578
+ store.sms_messages = [
1579
+ m for m in store.sms_messages if m.get("PhoneNumber") != filter_phone_number
1580
+ ]
1581
+ return Response("", status=204)
1582
+
1583
+ store.sms_messages.clear()
1584
+ return Response("", status=204)
1585
+
1586
+
1587
+ class SNSServiceSubscriptionTokenApiResource(SNSInternalResource):
1588
+ resource_type = "subscription-token"
1589
+ """Provides a REST API for retrospective access to Subscription Confirmation Tokens to confirm subscriptions.
1590
+ Those are not sent for email, and sometimes inaccessible when working with external HTTPS endpoint which won't be
1591
+ able to reach your local host.
1592
+
1593
+ This is registered as a LocalStack internal HTTP resource.
1594
+
1595
+ This endpoint has the following parameter:
1596
+ - GET `subscription_arn`: `subscriptionArn`resource in SNS for which you want the SubscriptionToken
1597
+ """
1598
+
1599
+ @route(f"{SUBSCRIPTION_TOKENS_ENDPOINT}/<path:subscription_arn>", methods=["GET"])
1600
+ @count_usage
1601
+ def on_get(self, _request: Request, subscription_arn: str):
1602
+ try:
1603
+ parsed_arn = parse_arn(subscription_arn)
1604
+ except InvalidArnException:
1605
+ response = Response("", 400)
1606
+ response.set_json(
1607
+ {
1608
+ "error": "The provided SubscriptionARN is invalid",
1609
+ "subscription_arn": subscription_arn,
1610
+ }
1611
+ )
1612
+ return response
1613
+
1614
+ store: SnsStore = sns_stores[parsed_arn["account"]][parsed_arn["region"]]
1615
+
1616
+ for token, sub_arn in store.subscription_tokens.items():
1617
+ if sub_arn == subscription_arn:
1618
+ return {
1619
+ "subscription_token": token,
1620
+ "subscription_arn": subscription_arn,
1621
+ }
1622
+
1623
+ response = Response("", 404)
1624
+ response.set_json(
1625
+ {
1626
+ "error": "The provided SubscriptionARN is not found",
1627
+ "subscription_arn": subscription_arn,
1628
+ }
1629
+ )
1630
+ return response