localstack-core 4.10.1.dev42__py3-none-any.whl → 4.12.1.dev18__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.

Potentially problematic release.


This version of localstack-core might be problematic. Click here for more details.

Files changed (158) hide show
  1. localstack/aws/api/apigateway/__init__.py +42 -0
  2. localstack/aws/api/cloudformation/__init__.py +161 -0
  3. localstack/aws/api/ec2/__init__.py +1178 -12
  4. localstack/aws/api/iam/__init__.py +228 -0
  5. localstack/aws/api/kms/__init__.py +1 -0
  6. localstack/aws/api/lambda_/__init__.py +1034 -66
  7. localstack/aws/api/logs/__init__.py +500 -0
  8. localstack/aws/api/opensearch/__init__.py +100 -0
  9. localstack/aws/api/redshift/__init__.py +69 -0
  10. localstack/aws/api/resourcegroupstaggingapi/__init__.py +36 -0
  11. localstack/aws/api/route53/__init__.py +45 -0
  12. localstack/aws/api/route53resolver/__init__.py +1 -0
  13. localstack/aws/api/s3/__init__.py +64 -0
  14. localstack/aws/api/s3control/__init__.py +19 -0
  15. localstack/aws/api/secretsmanager/__init__.py +37 -23
  16. localstack/aws/api/stepfunctions/__init__.py +52 -10
  17. localstack/aws/api/sts/__init__.py +52 -0
  18. localstack/aws/connect.py +35 -15
  19. localstack/aws/handlers/logging.py +8 -4
  20. localstack/aws/handlers/service.py +11 -2
  21. localstack/aws/protocol/serializer.py +1 -1
  22. localstack/config.py +8 -0
  23. localstack/constants.py +3 -0
  24. localstack/deprecations.py +0 -6
  25. localstack/dev/kubernetes/__main__.py +39 -14
  26. localstack/runtime/analytics.py +11 -0
  27. localstack/services/acm/provider.py +17 -1
  28. localstack/services/apigateway/legacy/provider.py +28 -15
  29. localstack/services/cloudformation/engine/template_preparer.py +6 -2
  30. localstack/services/cloudformation/engine/v2/change_set_model.py +9 -0
  31. localstack/services/cloudformation/engine/v2/change_set_model_preproc.py +15 -1
  32. localstack/services/cloudformation/engine/v2/change_set_resource_support_checker.py +114 -0
  33. localstack/services/cloudformation/provider.py +26 -1
  34. localstack/services/cloudformation/provider_utils.py +20 -0
  35. localstack/services/cloudformation/resource_provider.py +5 -4
  36. localstack/services/cloudformation/scaffolding/__main__.py +94 -22
  37. localstack/services/cloudformation/v2/provider.py +41 -0
  38. localstack/services/cloudwatch/provider.py +10 -3
  39. localstack/services/cloudwatch/provider_v2.py +6 -3
  40. localstack/services/configservice/provider.py +5 -1
  41. localstack/services/dynamodb/provider.py +1 -0
  42. localstack/services/dynamodb/v2/provider.py +1 -0
  43. localstack/services/dynamodbstreams/provider.py +6 -0
  44. localstack/services/dynamodbstreams/v2/provider.py +6 -0
  45. localstack/services/ec2/provider.py +6 -0
  46. localstack/services/es/provider.py +6 -0
  47. localstack/services/events/provider.py +4 -0
  48. localstack/services/events/v1/provider.py +9 -0
  49. localstack/services/firehose/provider.py +5 -0
  50. localstack/services/iam/provider.py +4 -0
  51. localstack/services/kinesis/packages.py +1 -1
  52. localstack/services/kms/models.py +16 -22
  53. localstack/services/kms/provider.py +4 -0
  54. localstack/services/lambda_/analytics.py +11 -2
  55. localstack/services/lambda_/api_utils.py +37 -20
  56. localstack/services/lambda_/event_source_mapping/pollers/stream_poller.py +1 -1
  57. localstack/services/lambda_/invocation/assignment.py +4 -1
  58. localstack/services/lambda_/invocation/event_manager.py +15 -11
  59. localstack/services/lambda_/invocation/execution_environment.py +21 -2
  60. localstack/services/lambda_/invocation/lambda_models.py +31 -2
  61. localstack/services/lambda_/invocation/lambda_service.py +62 -3
  62. localstack/services/lambda_/invocation/models.py +9 -1
  63. localstack/services/lambda_/invocation/version_manager.py +18 -3
  64. localstack/services/lambda_/provider.py +307 -106
  65. localstack/services/lambda_/resource_providers/aws_lambda_function.py +33 -1
  66. localstack/services/lambda_/runtimes.py +3 -1
  67. localstack/services/logs/provider.py +9 -0
  68. localstack/services/opensearch/packages.py +34 -20
  69. localstack/services/opensearch/provider.py +53 -3
  70. localstack/services/resource_groups/provider.py +5 -1
  71. localstack/services/resourcegroupstaggingapi/provider.py +6 -1
  72. localstack/services/route53/provider.py +7 -0
  73. localstack/services/route53resolver/provider.py +5 -0
  74. localstack/services/s3/constants.py +5 -0
  75. localstack/services/s3/exceptions.py +9 -0
  76. localstack/services/s3/models.py +9 -1
  77. localstack/services/s3/provider.py +51 -43
  78. localstack/services/s3/utils.py +81 -15
  79. localstack/services/s3control/provider.py +107 -2
  80. localstack/services/s3control/validation.py +50 -0
  81. localstack/services/scheduler/provider.py +4 -2
  82. localstack/services/secretsmanager/provider.py +4 -0
  83. localstack/services/ses/provider.py +4 -0
  84. localstack/services/sns/constants.py +16 -1
  85. localstack/services/sns/provider.py +5 -0
  86. localstack/services/sns/publisher.py +15 -6
  87. localstack/services/sns/v2/models.py +9 -0
  88. localstack/services/sns/v2/provider.py +750 -19
  89. localstack/services/sns/v2/utils.py +12 -0
  90. localstack/services/sqs/constants.py +6 -0
  91. localstack/services/sqs/provider.py +9 -1
  92. localstack/services/sqs/resource_providers/aws_sqs_queue.py +61 -46
  93. localstack/services/ssm/provider.py +6 -0
  94. localstack/services/stepfunctions/asl/component/common/path/result_path.py +1 -1
  95. localstack/services/stepfunctions/asl/component/state/state_execution/execute_state.py +0 -1
  96. localstack/services/stepfunctions/asl/component/state/state_execution/state_map/state_map.py +0 -1
  97. localstack/services/stepfunctions/asl/component/state/state_execution/state_task/lambda_eval_utils.py +8 -8
  98. localstack/services/stepfunctions/asl/component/state/state_execution/state_task/{mock_eval_utils.py → local_mock_eval_utils.py} +13 -9
  99. localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service.py +6 -6
  100. localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_callback.py +1 -1
  101. localstack/services/stepfunctions/asl/component/state/state_fail/state_fail.py +4 -0
  102. localstack/services/stepfunctions/asl/component/test_state/state/base_mock.py +118 -0
  103. localstack/services/stepfunctions/asl/component/test_state/state/common.py +82 -0
  104. localstack/services/stepfunctions/asl/component/test_state/state/execution.py +139 -0
  105. localstack/services/stepfunctions/asl/component/test_state/state/map.py +77 -0
  106. localstack/services/stepfunctions/asl/component/test_state/state/task.py +44 -0
  107. localstack/services/stepfunctions/asl/eval/environment.py +30 -22
  108. localstack/services/stepfunctions/asl/eval/states.py +1 -1
  109. localstack/services/stepfunctions/asl/eval/test_state/environment.py +49 -9
  110. localstack/services/stepfunctions/asl/eval/test_state/program_state.py +22 -0
  111. localstack/services/stepfunctions/asl/jsonata/jsonata.py +5 -1
  112. localstack/services/stepfunctions/asl/parse/preprocessor.py +67 -24
  113. localstack/services/stepfunctions/asl/parse/test_state/asl_parser.py +5 -4
  114. localstack/services/stepfunctions/asl/parse/test_state/preprocessor.py +222 -31
  115. localstack/services/stepfunctions/asl/static_analyser/test_state/test_state_analyser.py +256 -22
  116. localstack/services/stepfunctions/backend/execution.py +10 -11
  117. localstack/services/stepfunctions/backend/execution_worker.py +5 -5
  118. localstack/services/stepfunctions/backend/test_state/execution.py +36 -0
  119. localstack/services/stepfunctions/backend/test_state/execution_worker.py +33 -1
  120. localstack/services/stepfunctions/backend/test_state/test_state_mock.py +127 -0
  121. localstack/services/stepfunctions/local_mocking/__init__.py +9 -0
  122. localstack/services/stepfunctions/{mocking → local_mocking}/mock_config.py +24 -17
  123. localstack/services/stepfunctions/provider.py +83 -25
  124. localstack/services/stepfunctions/test_state/mock_config.py +47 -0
  125. localstack/services/sts/provider.py +7 -0
  126. localstack/services/support/provider.py +5 -1
  127. localstack/services/swf/provider.py +5 -1
  128. localstack/services/transcribe/provider.py +7 -0
  129. localstack/testing/aws/lambda_utils.py +1 -1
  130. localstack/testing/aws/util.py +2 -1
  131. localstack/testing/config.py +1 -0
  132. localstack/testing/pytest/fixtures.py +28 -0
  133. localstack/testing/snapshots/transformer_utility.py +5 -0
  134. localstack/utils/analytics/publisher.py +37 -155
  135. localstack/utils/analytics/service_request_aggregator.py +6 -4
  136. localstack/utils/aws/arns.py +7 -0
  137. localstack/utils/aws/client_types.py +2 -4
  138. localstack/utils/batching.py +258 -0
  139. localstack/utils/bootstrap.py +2 -2
  140. localstack/utils/catalog/catalog.py +3 -2
  141. localstack/utils/collections.py +23 -11
  142. localstack/utils/container_utils/container_client.py +22 -13
  143. localstack/utils/container_utils/docker_cmd_client.py +6 -6
  144. localstack/version.py +2 -2
  145. {localstack_core-4.10.1.dev42.dist-info → localstack_core-4.12.1.dev18.dist-info}/METADATA +7 -7
  146. {localstack_core-4.10.1.dev42.dist-info → localstack_core-4.12.1.dev18.dist-info}/RECORD +155 -146
  147. localstack_core-4.12.1.dev18.dist-info/plux.json +1 -0
  148. localstack/services/stepfunctions/mocking/__init__.py +0 -0
  149. localstack/utils/batch_policy.py +0 -124
  150. localstack_core-4.10.1.dev42.dist-info/plux.json +0 -1
  151. /localstack/services/stepfunctions/{mocking → local_mocking}/mock_config_file.py +0 -0
  152. {localstack_core-4.10.1.dev42.data → localstack_core-4.12.1.dev18.data}/scripts/localstack +0 -0
  153. {localstack_core-4.10.1.dev42.data → localstack_core-4.12.1.dev18.data}/scripts/localstack-supervisor +0 -0
  154. {localstack_core-4.10.1.dev42.data → localstack_core-4.12.1.dev18.data}/scripts/localstack.bat +0 -0
  155. {localstack_core-4.10.1.dev42.dist-info → localstack_core-4.12.1.dev18.dist-info}/WHEEL +0 -0
  156. {localstack_core-4.10.1.dev42.dist-info → localstack_core-4.12.1.dev18.dist-info}/entry_points.txt +0 -0
  157. {localstack_core-4.10.1.dev42.dist-info → localstack_core-4.12.1.dev18.dist-info}/licenses/LICENSE.txt +0 -0
  158. {localstack_core-4.10.1.dev42.dist-info → localstack_core-4.12.1.dev18.dist-info}/top_level.txt +0 -0
@@ -1,26 +1,35 @@
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 (
13
+ ActionsList,
11
14
  AmazonResourceName,
15
+ BatchEntryIdsNotDistinctException,
16
+ CheckIfPhoneNumberIsOptedOutResponse,
12
17
  ConfirmSubscriptionResponse,
13
18
  CreateEndpointResponse,
14
19
  CreatePlatformApplicationResponse,
15
20
  CreateTopicResponse,
21
+ DelegatesList,
16
22
  Endpoint,
23
+ EndpointDisabledException,
17
24
  GetEndpointAttributesResponse,
18
25
  GetPlatformApplicationAttributesResponse,
19
26
  GetSMSAttributesResponse,
20
27
  GetSubscriptionAttributesResponse,
21
28
  GetTopicAttributesResponse,
22
29
  InvalidParameterException,
30
+ InvalidParameterValueException,
23
31
  ListEndpointsByPlatformApplicationResponse,
32
+ ListPhoneNumbersOptedOutResponse,
24
33
  ListPlatformApplicationsResponse,
25
34
  ListString,
26
35
  ListSubscriptionsByTopicResponse,
@@ -28,8 +37,15 @@ from localstack.aws.api.sns import (
28
37
  ListTagsForResourceResponse,
29
38
  ListTopicsResponse,
30
39
  MapStringToString,
40
+ MessageAttributeMap,
31
41
  NotFoundException,
42
+ OptInPhoneNumberResponse,
43
+ PhoneNumber,
32
44
  PlatformApplication,
45
+ PublishBatchRequestEntryList,
46
+ PublishBatchResponse,
47
+ PublishBatchResultEntry,
48
+ PublishResponse,
33
49
  SetSMSAttributesResponse,
34
50
  SnsApi,
35
51
  String,
@@ -39,26 +55,51 @@ from localstack.aws.api.sns import (
39
55
  TagKeyList,
40
56
  TagList,
41
57
  TagResourceResponse,
58
+ TooManyEntriesInBatchRequestException,
42
59
  TopicAttributesMap,
43
60
  UntagResourceResponse,
44
61
  attributeName,
45
62
  attributeValue,
46
63
  authenticateOnUnsubscribe,
47
64
  endpoint,
65
+ label,
66
+ message,
67
+ messageStructure,
48
68
  nextToken,
49
69
  protocol,
70
+ string,
71
+ subject,
50
72
  subscriptionARN,
51
73
  topicARN,
52
74
  topicName,
53
75
  )
54
- from localstack.services.sns import constants as sns_constants
76
+ from localstack.constants import AWS_REGION_US_EAST_1, DEFAULT_AWS_ACCOUNT_ID
77
+ from localstack.http import Response
78
+ from localstack.services.edge import ROUTER
79
+ from localstack.services.plugins import ServiceLifecycleHook
80
+ from localstack.services.sns.analytics import internal_api_calls
55
81
  from localstack.services.sns.certificate import SNS_SERVER_CERT
56
82
  from localstack.services.sns.constants import (
83
+ ATTR_TYPE_REGEX,
57
84
  DUMMY_SUBSCRIPTION_PRINCIPAL,
85
+ MAXIMUM_MESSAGE_LENGTH,
86
+ MSG_ATTR_NAME_REGEX,
87
+ PLATFORM_ENDPOINT_MSGS_ENDPOINT,
88
+ SMS_MSGS_ENDPOINT,
89
+ SNS_CERT_ENDPOINT,
90
+ SNS_PROTOCOLS,
91
+ SUBSCRIPTION_TOKENS_ENDPOINT,
58
92
  VALID_APPLICATION_PLATFORMS,
93
+ VALID_MSG_ATTR_NAME_CHARS,
94
+ VALID_POLICY_ACTIONS,
95
+ VALID_SUBSCRIPTION_ATTR_NAME,
59
96
  )
60
97
  from localstack.services.sns.filter import FilterPolicyValidator
61
- from localstack.services.sns.publisher import PublishDispatcher, SnsPublishContext
98
+ from localstack.services.sns.publisher import (
99
+ PublishDispatcher,
100
+ SnsBatchPublishContext,
101
+ SnsPublishContext,
102
+ )
62
103
  from localstack.services.sns.v2.models import (
63
104
  SMS_ATTRIBUTE_NAMES,
64
105
  SMS_DEFAULT_SENDER_REGEX,
@@ -79,18 +120,23 @@ from localstack.services.sns.v2.utils import (
79
120
  encode_subscription_token_with_region,
80
121
  get_next_page_token_from_arn,
81
122
  get_region_from_subscription_token,
123
+ get_topic_subscriptions,
82
124
  is_valid_e164_number,
83
125
  parse_and_validate_platform_application_arn,
84
126
  parse_and_validate_topic_arn,
85
127
  validate_subscription_attribute,
86
128
  )
129
+ from localstack.state import StateVisitor
87
130
  from localstack.utils.aws.arns import (
131
+ extract_account_id_from_arn,
132
+ extract_region_from_arn,
88
133
  get_partition,
89
134
  parse_arn,
90
135
  sns_platform_application_arn,
91
136
  sns_topic_arn,
92
137
  )
93
138
  from localstack.utils.collections import PaginatedList, select_from_typed_dict
139
+ from localstack.utils.strings import to_bytes
94
140
 
95
141
  # set up logger
96
142
  LOG = logging.getLogger(__name__)
@@ -99,12 +145,30 @@ SNS_TOPIC_NAME_PATTERN_FIFO = r"^[a-zA-Z0-9_-]{1,256}\.fifo$"
99
145
  SNS_TOPIC_NAME_PATTERN = r"^[a-zA-Z0-9_-]{1,256}$"
100
146
 
101
147
 
102
- class SnsProvider(SnsApi):
148
+ class SnsProvider(SnsApi, ServiceLifecycleHook):
103
149
  def __init__(self) -> None:
104
150
  super().__init__()
105
151
  self._publisher = PublishDispatcher()
106
152
  self._signature_cert_pem: str = SNS_SERVER_CERT
107
153
 
154
+ def accept_state_visitor(self, visitor: StateVisitor):
155
+ visitor.visit(sns_stores)
156
+
157
+ def on_before_stop(self):
158
+ self._publisher.shutdown()
159
+
160
+ def on_after_init(self):
161
+ # Allow sent platform endpoint messages to be retrieved from the SNS endpoint
162
+ register_sns_api_resource(ROUTER)
163
+ # add the route to serve the certificate used to validate message signatures
164
+ ROUTER.add(self.get_signature_cert_pem_file)
165
+
166
+ @route(SNS_CERT_ENDPOINT, methods=["GET"])
167
+ def get_signature_cert_pem_file(self, request: Request):
168
+ # see http://sns-public-resources.s3.amazonaws.com/SNS_Message_Signing_Release_Note_Jan_25_2011.pdf
169
+ # see https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message.html
170
+ return Response(self._signature_cert_pem, 200)
171
+
108
172
  ## Topic Operations
109
173
 
110
174
  def create_topic(
@@ -135,7 +199,6 @@ class SnsProvider(SnsApi):
135
199
  )
136
200
  return CreateTopicResponse(TopicArn=topic_arn)
137
201
 
138
- attributes = attributes or {}
139
202
  if attributes.get("FifoTopic") and attributes["FifoTopic"].lower() == "true":
140
203
  fifo_match = re.match(SNS_TOPIC_NAME_PATTERN_FIFO, name)
141
204
  if not fifo_match:
@@ -219,14 +282,12 @@ class SnsProvider(SnsApi):
219
282
 
220
283
  store = self.get_store(account_id=parsed_topic_arn["account"], region=context.region)
221
284
 
222
- if topic_arn not in store.topics:
223
- raise NotFoundException("Topic does not exist")
224
-
225
- topic_subscriptions = store.topics[topic_arn]["subscriptions"]
285
+ topic = self._get_topic(arn=topic_arn, context=context)
286
+ topic_subscriptions = topic["subscriptions"]
226
287
  if not endpoint:
227
288
  # TODO: check AWS behaviour (because endpoint is optional)
228
289
  raise NotFoundException("Endpoint not specified in subscription")
229
- if protocol not in sns_constants.SNS_PROTOCOLS:
290
+ if protocol not in SNS_PROTOCOLS:
230
291
  raise InvalidParameterException(
231
292
  f"Invalid parameter: Amazon SNS does not support this protocol string: {protocol}"
232
293
  )
@@ -276,7 +337,7 @@ class SnsProvider(SnsApi):
276
337
  if sub.get("Endpoint") == endpoint:
277
338
  if sub_attributes:
278
339
  # validate the subscription attributes aren't different
279
- for attr in sns_constants.VALID_SUBSCRIPTION_ATTR_NAME:
340
+ for attr in VALID_SUBSCRIPTION_ATTR_NAME:
280
341
  # if a new attribute is present and different from an existent one, raise
281
342
  if (new_attr := sub_attributes.get(attr)) and sub.get(attr) != new_attr:
282
343
  raise InvalidParameterException(
@@ -338,8 +399,7 @@ class SnsProvider(SnsApi):
338
399
  message=message_ctx,
339
400
  store=store,
340
401
  request_headers=context.request.headers,
341
- # TODO: add topic attributes once they are ported from moto to LocalStack
342
- # topic_attributes=vars(self._get_topic(topic_arn, context)),
402
+ topic_attributes=topic["attributes"],
343
403
  )
344
404
  self._publisher.publish_to_topic_subscriber(
345
405
  ctx=publish_ctx,
@@ -539,11 +599,10 @@ class SnsProvider(SnsApi):
539
599
  def list_subscriptions_by_topic(
540
600
  self, context: RequestContext, topic_arn: topicARN, next_token: nextToken = None, **kwargs
541
601
  ) -> ListSubscriptionsByTopicResponse:
542
- topic: Topic = self._get_topic(topic_arn, context)
602
+ self._get_topic(topic_arn, context) # for validation purposes only
543
603
  parsed_topic_arn = parse_and_validate_topic_arn(topic_arn)
544
604
  store = self.get_store(parsed_topic_arn["account"], parsed_topic_arn["region"])
545
- sub_arns: list[str] = topic.get("subscriptions", [])
546
- subscriptions = [store.subscriptions[k] for k in sub_arns if k in store.subscriptions]
605
+ subscriptions = get_topic_subscriptions(store, topic_arn)
547
606
 
548
607
  paginated_subscriptions = PaginatedList(subscriptions)
549
608
  page, next_token = paginated_subscriptions.get_page(
@@ -557,6 +616,238 @@ class SnsProvider(SnsApi):
557
616
  response["NextToken"] = next_token
558
617
  return response
559
618
 
619
+ #
620
+ # Publish
621
+ #
622
+
623
+ def publish(
624
+ self,
625
+ context: RequestContext,
626
+ message: message,
627
+ topic_arn: topicARN | None = None,
628
+ target_arn: String | None = None,
629
+ phone_number: PhoneNumber | None = None,
630
+ subject: subject | None = None,
631
+ message_structure: messageStructure | None = None,
632
+ message_attributes: MessageAttributeMap | None = None,
633
+ message_deduplication_id: String | None = None,
634
+ message_group_id: String | None = None,
635
+ **kwargs,
636
+ ) -> PublishResponse:
637
+ if subject == "":
638
+ raise InvalidParameterException("Invalid parameter: Subject")
639
+ if not message or all(not m for m in message):
640
+ raise InvalidParameterException("Invalid parameter: Empty message")
641
+
642
+ # TODO: check for topic + target + phone number at the same time?
643
+ # TODO: more validation on phone, it might be opted out?
644
+ if phone_number and not is_valid_e164_number(phone_number):
645
+ raise InvalidParameterException(
646
+ f"Invalid parameter: PhoneNumber Reason: {phone_number} is not valid to publish to"
647
+ )
648
+
649
+ if message_attributes:
650
+ _validate_message_attributes(message_attributes)
651
+
652
+ if _get_total_publish_size(message, message_attributes) > MAXIMUM_MESSAGE_LENGTH:
653
+ raise InvalidParameterException("Invalid parameter: Message too long")
654
+
655
+ # for compatibility reasons, AWS allows users to use either TargetArn or TopicArn for publishing to a topic
656
+ # use any of them for topic validation
657
+ topic_or_target_arn = topic_arn or target_arn
658
+ topic = None
659
+
660
+ if is_fifo := (topic_or_target_arn and ".fifo" in topic_or_target_arn):
661
+ if not message_group_id:
662
+ raise InvalidParameterException(
663
+ "Invalid parameter: The MessageGroupId parameter is required for FIFO topics",
664
+ )
665
+ topic = self._get_topic(topic_or_target_arn, context)
666
+ if topic["attributes"]["ContentBasedDeduplication"] == "false":
667
+ if not message_deduplication_id:
668
+ raise InvalidParameterException(
669
+ "Invalid parameter: The topic should either have ContentBasedDeduplication enabled or MessageDeduplicationId provided explicitly",
670
+ )
671
+ elif message_deduplication_id:
672
+ # this is the first one to raise if both are set while the topic is not fifo
673
+ raise InvalidParameterException(
674
+ "Invalid parameter: MessageDeduplicationId Reason: The request includes MessageDeduplicationId parameter that is not valid for this topic type"
675
+ )
676
+
677
+ is_endpoint_publish = target_arn and ":endpoint/" in target_arn
678
+ if message_structure == "json":
679
+ try:
680
+ message = json.loads(message)
681
+ # Keys in the JSON object that correspond to supported transport protocols must have
682
+ # simple JSON string values.
683
+ # Non-string values will cause the key to be ignored.
684
+ message = {key: field for key, field in message.items() if isinstance(field, str)}
685
+ # TODO: check no default key for direct TargetArn endpoint publish, need credentials
686
+ # see example: https://docs.aws.amazon.com/sns/latest/dg/sns-send-custom-platform-specific-payloads-mobile-devices.html
687
+ if "default" not in message and not is_endpoint_publish:
688
+ raise InvalidParameterException(
689
+ "Invalid parameter: Message Structure - No default entry in JSON message body"
690
+ )
691
+ except json.JSONDecodeError:
692
+ raise InvalidParameterException(
693
+ "Invalid parameter: Message Structure - JSON message body failed to parse"
694
+ )
695
+
696
+ if not phone_number:
697
+ # use the account to get the store from the TopicArn (you can only publish in the same region as the topic)
698
+ parsed_arn = parse_and_validate_topic_arn(topic_or_target_arn)
699
+ store = self.get_store(account_id=parsed_arn["account"], region=context.region)
700
+ if is_endpoint_publish:
701
+ if not (platform_endpoint := store.platform_endpoints.get(target_arn)):
702
+ raise InvalidParameterException(
703
+ "Invalid parameter: TargetArn Reason: No endpoint found for the target arn specified"
704
+ )
705
+ elif (
706
+ not platform_endpoint.platform_endpoint["Attributes"]
707
+ .get("Enabled", "false")
708
+ .lower()
709
+ == "true"
710
+ ):
711
+ raise EndpointDisabledException("Endpoint is disabled")
712
+ else:
713
+ topic = self._get_topic(topic_or_target_arn, context)
714
+ else:
715
+ # use the store from the request context
716
+ store = self.get_store(account_id=context.account_id, region=context.region)
717
+
718
+ message_ctx = SnsMessage(
719
+ type=SnsMessageType.Notification,
720
+ message=message,
721
+ message_attributes=message_attributes,
722
+ message_deduplication_id=message_deduplication_id,
723
+ message_group_id=message_group_id,
724
+ message_structure=message_structure,
725
+ subject=subject,
726
+ is_fifo=is_fifo,
727
+ )
728
+ publish_ctx = SnsPublishContext(
729
+ message=message_ctx, store=store, request_headers=context.request.headers
730
+ )
731
+
732
+ if is_endpoint_publish:
733
+ self._publisher.publish_to_application_endpoint(
734
+ ctx=publish_ctx, endpoint_arn=target_arn
735
+ )
736
+ elif phone_number:
737
+ self._publisher.publish_to_phone_number(ctx=publish_ctx, phone_number=phone_number)
738
+ else:
739
+ # beware if the subscription is FIFO, the order might not be guaranteed.
740
+ # 2 quick call to this method in succession might not be executed in order in the executor?
741
+ # TODO: test how this behaves in a FIFO context with a lot of threads.
742
+ publish_ctx.topic_attributes |= topic["attributes"]
743
+ self._publisher.publish_to_topic(publish_ctx, topic_or_target_arn)
744
+
745
+ if is_fifo:
746
+ return PublishResponse(
747
+ MessageId=message_ctx.message_id, SequenceNumber=message_ctx.sequencer_number
748
+ )
749
+
750
+ return PublishResponse(MessageId=message_ctx.message_id)
751
+
752
+ def publish_batch(
753
+ self,
754
+ context: RequestContext,
755
+ topic_arn: topicARN,
756
+ publish_batch_request_entries: PublishBatchRequestEntryList,
757
+ **kwargs,
758
+ ) -> PublishBatchResponse:
759
+ if len(publish_batch_request_entries) > 10:
760
+ raise TooManyEntriesInBatchRequestException(
761
+ "The batch request contains more entries than permissible."
762
+ )
763
+
764
+ parsed_arn = parse_and_validate_topic_arn(topic_arn)
765
+ store = self.get_store(account_id=parsed_arn["account"], region=context.region)
766
+ topic = self._get_topic(topic_arn, context)
767
+ ids = [entry["Id"] for entry in publish_batch_request_entries]
768
+ if len(set(ids)) != len(publish_batch_request_entries):
769
+ raise BatchEntryIdsNotDistinctException(
770
+ "Two or more batch entries in the request have the same Id."
771
+ )
772
+
773
+ response: PublishBatchResponse = {"Successful": [], "Failed": []}
774
+
775
+ # TODO: write AWS validated tests with FilterPolicy and batching
776
+ # TODO: find a scenario where we can fail to send a message synchronously to be able to report it
777
+ # right now, it seems that AWS fails the whole publish if something is wrong in the format of 1 message
778
+
779
+ total_batch_size = 0
780
+ message_contexts = []
781
+ for entry_index, entry in enumerate(publish_batch_request_entries, start=1):
782
+ message_payload = entry.get("Message")
783
+ message_attributes = entry.get("MessageAttributes", {})
784
+ if message_attributes:
785
+ # if a message contains non-valid message attributes, it
786
+ # will fail for the first non-valid message encountered, and raise ParameterValueInvalid
787
+ _validate_message_attributes(message_attributes, position=entry_index)
788
+
789
+ total_batch_size += _get_total_publish_size(message_payload, message_attributes)
790
+
791
+ # TODO: WRITE AWS VALIDATED
792
+ if entry.get("MessageStructure") == "json":
793
+ try:
794
+ message = json.loads(message_payload)
795
+ # Keys in the JSON object that correspond to supported transport protocols must have
796
+ # simple JSON string values.
797
+ # Non-string values will cause the key to be ignored.
798
+ message = {
799
+ key: field for key, field in message.items() if isinstance(field, str)
800
+ }
801
+ if "default" not in message:
802
+ raise InvalidParameterException(
803
+ "Invalid parameter: Message Structure - No default entry in JSON message body"
804
+ )
805
+ entry["Message"] = message # noqa
806
+ except json.JSONDecodeError:
807
+ raise InvalidParameterException(
808
+ "Invalid parameter: Message Structure - JSON message body failed to parse"
809
+ )
810
+
811
+ if is_fifo := (topic_arn.endswith(".fifo")):
812
+ if not all("MessageGroupId" in entry for entry in publish_batch_request_entries):
813
+ raise InvalidParameterException(
814
+ "Invalid parameter: The MessageGroupId parameter is required for FIFO topics"
815
+ )
816
+ if topic["attributes"]["ContentBasedDeduplication"] == "false":
817
+ if not all(
818
+ "MessageDeduplicationId" in entry for entry in publish_batch_request_entries
819
+ ):
820
+ raise InvalidParameterException(
821
+ "Invalid parameter: The topic should either have ContentBasedDeduplication enabled or MessageDeduplicationId provided explicitly",
822
+ )
823
+
824
+ msg_ctx = SnsMessage.from_batch_entry(entry, is_fifo=is_fifo)
825
+ message_contexts.append(msg_ctx)
826
+ success = PublishBatchResultEntry(
827
+ Id=entry["Id"],
828
+ MessageId=msg_ctx.message_id,
829
+ )
830
+ if is_fifo:
831
+ success["SequenceNumber"] = msg_ctx.sequencer_number
832
+ response["Successful"].append(success)
833
+
834
+ if total_batch_size > MAXIMUM_MESSAGE_LENGTH:
835
+ raise CommonServiceException(
836
+ code="BatchRequestTooLong",
837
+ message="The length of all the messages put together is more than the limit.",
838
+ sender_fault=True,
839
+ )
840
+
841
+ publish_ctx = SnsBatchPublishContext(
842
+ messages=message_contexts,
843
+ store=store,
844
+ request_headers=context.request.headers,
845
+ topic_attributes=topic["attributes"],
846
+ )
847
+ self._publisher.publish_batch_to_topic(publish_ctx, topic_arn)
848
+
849
+ return response
850
+
560
851
  #
561
852
  # PlatformApplications
562
853
  #
@@ -794,6 +1085,94 @@ class SnsProvider(SnsApi):
794
1085
 
795
1086
  return GetSMSAttributesResponse(attributes=return_attributes)
796
1087
 
1088
+ #
1089
+ # Phone number operations
1090
+ #
1091
+
1092
+ def check_if_phone_number_is_opted_out(
1093
+ self, context: RequestContext, phone_number: PhoneNumber, **kwargs
1094
+ ) -> CheckIfPhoneNumberIsOptedOutResponse:
1095
+ store = sns_stores[context.account_id][context.region]
1096
+ return CheckIfPhoneNumberIsOptedOutResponse(
1097
+ isOptedOut=phone_number in store.PHONE_NUMBERS_OPTED_OUT
1098
+ )
1099
+
1100
+ def list_phone_numbers_opted_out(
1101
+ self, context: RequestContext, next_token: string | None = None, **kwargs
1102
+ ) -> ListPhoneNumbersOptedOutResponse:
1103
+ store = self.get_store(context.account_id, context.region)
1104
+ numbers_opted_out = PaginatedList(store.PHONE_NUMBERS_OPTED_OUT)
1105
+ page, nxt = numbers_opted_out.get_page(
1106
+ token_generator=lambda x: x,
1107
+ next_token=next_token,
1108
+ page_size=100,
1109
+ )
1110
+ phone_numbers = {"phoneNumbers": page, "nextToken": nxt}
1111
+ return ListPhoneNumbersOptedOutResponse(**phone_numbers)
1112
+
1113
+ def opt_in_phone_number(
1114
+ self, context: RequestContext, phone_number: PhoneNumber, **kwargs
1115
+ ) -> OptInPhoneNumberResponse:
1116
+ store = self.get_store(context.account_id, context.region)
1117
+ if phone_number in store.PHONE_NUMBERS_OPTED_OUT:
1118
+ store.PHONE_NUMBERS_OPTED_OUT.remove(phone_number)
1119
+ return OptInPhoneNumberResponse()
1120
+
1121
+ #
1122
+ # Permission operations
1123
+ #
1124
+
1125
+ def add_permission(
1126
+ self,
1127
+ context: RequestContext,
1128
+ topic_arn: topicARN,
1129
+ label: label,
1130
+ aws_account_id: DelegatesList,
1131
+ action_name: ActionsList,
1132
+ **kwargs,
1133
+ ) -> None:
1134
+ topic: Topic = self._get_topic(topic_arn, context)
1135
+ policy = json.loads(topic["attributes"]["Policy"])
1136
+ statement = next(
1137
+ (statement for statement in policy["Statement"] if statement["Sid"] == label),
1138
+ None,
1139
+ )
1140
+
1141
+ if statement:
1142
+ raise InvalidParameterException("Invalid parameter: Statement already exists")
1143
+
1144
+ if any(action not in VALID_POLICY_ACTIONS for action in action_name):
1145
+ raise InvalidParameterException(
1146
+ "Invalid parameter: Policy statement action out of service scope!"
1147
+ )
1148
+
1149
+ principals = [
1150
+ f"arn:{get_partition(context.region)}:iam::{account_id}:root"
1151
+ for account_id in aws_account_id
1152
+ ]
1153
+ actions = [f"SNS:{action}" for action in action_name]
1154
+
1155
+ statement = {
1156
+ "Sid": label,
1157
+ "Effect": "Allow",
1158
+ "Principal": {"AWS": principals[0] if len(principals) == 1 else principals},
1159
+ "Action": actions[0] if len(actions) == 1 else actions,
1160
+ "Resource": topic_arn,
1161
+ }
1162
+
1163
+ policy["Statement"].append(statement)
1164
+ topic["attributes"]["Policy"] = json.dumps(policy)
1165
+
1166
+ def remove_permission(
1167
+ self, context: RequestContext, topic_arn: topicARN, label: label, **kwargs
1168
+ ) -> None:
1169
+ topic = self._get_topic(topic_arn, context)
1170
+ policy = json.loads(topic["attributes"]["Policy"])
1171
+ statements = policy["Statement"]
1172
+ statements = [statement for statement in statements if statement["Sid"] != label]
1173
+ policy["Statement"] = statements
1174
+ topic["attributes"]["Policy"] = json.dumps(policy)
1175
+
797
1176
  def list_tags_for_resource(
798
1177
  self, context: RequestContext, resource_arn: AmazonResourceName, **kwargs
799
1178
  ) -> ListTagsForResourceResponse:
@@ -826,16 +1205,15 @@ class SnsProvider(SnsApi):
826
1205
  def get_store(account_id: str, region: str) -> SnsStore:
827
1206
  return sns_stores[account_id][region]
828
1207
 
829
- # TODO: reintroduce multi-region parameter (latest before final migration from v1)
830
1208
  @staticmethod
831
- def _get_topic(arn: str, context: RequestContext) -> Topic:
1209
+ def _get_topic(arn: str, context: RequestContext, multi_region: bool = False) -> Topic:
832
1210
  """
833
1211
  :param arn: the Topic ARN
834
1212
  :param context: the RequestContext of the request
835
1213
  :return: the model Topic
836
1214
  """
837
1215
  arn_data = parse_and_validate_topic_arn(arn)
838
- if context.region != arn_data["region"]:
1216
+ if not multi_region and context.region != arn_data["region"]:
839
1217
  raise InvalidParameterException("Invalid parameter: TopicArn")
840
1218
  try:
841
1219
  store = SnsProvider.get_store(context.account_id, context.region)
@@ -887,7 +1265,6 @@ def _default_attributes(topic: Topic, context: RequestContext) -> TopicAttribute
887
1265
  {
888
1266
  "ContentBasedDeduplication": "false",
889
1267
  "FifoTopic": "false",
890
- "SignatureVersion": "2",
891
1268
  }
892
1269
  )
893
1270
  return default_attributes
@@ -921,6 +1298,94 @@ def _create_default_topic_policy(topic: Topic, context: RequestContext) -> str:
921
1298
  )
922
1299
 
923
1300
 
1301
+ def _validate_message_attributes(
1302
+ message_attributes: MessageAttributeMap, position: int | None = None
1303
+ ) -> None:
1304
+ """
1305
+ Validate the message attributes, and raises an exception if those do not follow AWS validation
1306
+ See: https://docs.aws.amazon.com/sns/latest/dg/sns-message-attributes.html
1307
+ Regex from: https://stackoverflow.com/questions/40718851/regex-that-does-not-allow-consecutive-dots
1308
+ :param message_attributes: the message attributes map for the message
1309
+ :param position: given to give the Batch Entry position if coming from `publishBatch`
1310
+ :raises: InvalidParameterValueException
1311
+ :return: None
1312
+ """
1313
+ for attr_name, attr in message_attributes.items():
1314
+ if len(attr_name) > 256:
1315
+ raise InvalidParameterValueException(
1316
+ "Length of message attribute name must be less than 256 bytes."
1317
+ )
1318
+ _validate_message_attribute_name(attr_name)
1319
+ # `DataType` is a required field for MessageAttributeValue
1320
+ if (data_type := attr.get("DataType")) is None:
1321
+ if position:
1322
+ at = f"publishBatchRequestEntries.{position}.member.messageAttributes.{attr_name}.member.dataType"
1323
+ else:
1324
+ at = f"messageAttributes.{attr_name}.member.dataType"
1325
+
1326
+ raise CommonServiceException(
1327
+ code="ValidationError",
1328
+ message=f"1 validation error detected: Value null at '{at}' failed to satisfy constraint: Member must not be null",
1329
+ sender_fault=True,
1330
+ )
1331
+
1332
+ if data_type not in (
1333
+ "String",
1334
+ "Number",
1335
+ "Binary",
1336
+ ) and not ATTR_TYPE_REGEX.match(data_type):
1337
+ raise InvalidParameterValueException(
1338
+ f"The message attribute '{attr_name}' has an invalid message attribute type, the set of supported type prefixes is Binary, Number, and String."
1339
+ )
1340
+ if not any(attr_value.endswith("Value") for attr_value in attr):
1341
+ raise InvalidParameterValueException(
1342
+ f"The message attribute '{attr_name}' must contain non-empty message attribute value for message attribute type '{data_type}'."
1343
+ )
1344
+
1345
+ value_key_data_type = "Binary" if data_type.startswith("Binary") else "String"
1346
+ value_key = f"{value_key_data_type}Value"
1347
+ if value_key not in attr:
1348
+ raise InvalidParameterValueException(
1349
+ f"The message attribute '{attr_name}' with type '{data_type}' must use field '{value_key_data_type}'."
1350
+ )
1351
+ elif not attr[value_key]:
1352
+ raise InvalidParameterValueException(
1353
+ f"The message attribute '{attr_name}' must contain non-empty message attribute value for message attribute type '{data_type}'.",
1354
+ )
1355
+
1356
+
1357
+ def _validate_message_attribute_name(name: str) -> None:
1358
+ """
1359
+ Validate the message attribute name with the specification of AWS.
1360
+ 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.
1361
+ :param name: message attribute name
1362
+ :raises InvalidParameterValueException: if the name does not conform to the spec
1363
+ """
1364
+ if not MSG_ATTR_NAME_REGEX.match(name):
1365
+ # find the proper exception
1366
+ if name[0] == ".":
1367
+ raise InvalidParameterValueException(
1368
+ "Invalid message attribute name starting with character '.' was found."
1369
+ )
1370
+ elif name[-1] == ".":
1371
+ raise InvalidParameterValueException(
1372
+ "Invalid message attribute name ending with character '.' was found."
1373
+ )
1374
+
1375
+ for idx, char in enumerate(name):
1376
+ if char not in VALID_MSG_ATTR_NAME_CHARS:
1377
+ # change prefix from 0x to #x, without capitalizing the x
1378
+ hex_char = "#x" + hex(ord(char)).upper()[2:]
1379
+ raise InvalidParameterValueException(
1380
+ f"Invalid non-alphanumeric character '{hex_char}' was found in the message attribute name. Can only include alphanumeric characters, hyphens, underscores, or dots."
1381
+ )
1382
+ # even if we go negative index, it will be covered by starting/ending with dot
1383
+ if char == "." and name[idx - 1] == ".":
1384
+ raise InvalidParameterValueException(
1385
+ "Message attribute name can not have successive '.' character."
1386
+ )
1387
+
1388
+
924
1389
  def _validate_platform_application_name(name: str) -> None:
925
1390
  reason = ""
926
1391
  if not name:
@@ -997,3 +1462,269 @@ def _check_matching_tags(topic_arn: str, tags: TagList | None, store: SnsStore)
997
1462
  if existing_tags is not None and tag not in existing_tags:
998
1463
  return False
999
1464
  return True
1465
+
1466
+
1467
+ def _get_total_publish_size(
1468
+ message_body: str, message_attributes: MessageAttributeMap | None
1469
+ ) -> int:
1470
+ size = _get_byte_size(message_body)
1471
+ if message_attributes:
1472
+ # https://docs.aws.amazon.com/sns/latest/dg/sns-message-attributes.html
1473
+ # All parts of the message attribute, including name, type, and value, are included in the message size
1474
+ # restriction, which is 256 KB.
1475
+ # iterate over the Keys and Attributes, adding the length of the Key to the length of all Attributes values
1476
+ # (DataType and StringValue or BinaryValue)
1477
+ size += sum(
1478
+ _get_byte_size(key) + sum(_get_byte_size(attr_value) for attr_value in attr.values())
1479
+ for key, attr in message_attributes.items()
1480
+ )
1481
+
1482
+ return size
1483
+
1484
+
1485
+ def _get_byte_size(payload: str | bytes) -> int:
1486
+ # Calculate the real length of the byte object if the object is a string
1487
+ return len(to_bytes(payload))
1488
+
1489
+
1490
+ def _register_sns_api_resource(router: Router):
1491
+ """Register the retrospection endpoints as internal LocalStack endpoints."""
1492
+ router.add(SNSServicePlatformEndpointMessagesApiResource())
1493
+ router.add(SNSServiceSMSMessagesApiResource())
1494
+ router.add(SNSServiceSubscriptionTokenApiResource())
1495
+
1496
+
1497
+ class SNSInternalResource:
1498
+ resource_type: str
1499
+ """Base class with helper to properly track usage of internal endpoints"""
1500
+
1501
+ def count_usage(self):
1502
+ internal_api_calls.labels(resource_type=self.resource_type).increment()
1503
+
1504
+
1505
+ def count_usage(f):
1506
+ @functools.wraps(f)
1507
+ def _wrapper(self, *args, **kwargs):
1508
+ self.count_usage()
1509
+ return f(self, *args, **kwargs)
1510
+
1511
+ return _wrapper
1512
+
1513
+
1514
+ class SNSServicePlatformEndpointMessagesApiResource(SNSInternalResource):
1515
+ resource_type = "platform-endpoint-message"
1516
+ """Provides a REST API for retrospective access to platform endpoint messages sent via SNS.
1517
+
1518
+ This is registered as a LocalStack internal HTTP resource.
1519
+
1520
+ This endpoint accepts:
1521
+ - GET param `accountId`: selector for AWS account. If not specified, return fallback `000000000000` test ID
1522
+ - GET param `region`: selector for AWS `region`. If not specified, return default "us-east-1"
1523
+ - GET param `endpointArn`: filter for `endpointArn` resource in SNS
1524
+ - DELETE param `accountId`: selector for AWS account
1525
+ - DELETE param `region`: will delete saved messages for `region`
1526
+ - DELETE param `endpointArn`: will delete saved messages for `endpointArn`
1527
+ """
1528
+
1529
+ _PAYLOAD_FIELDS = [
1530
+ "TargetArn",
1531
+ "TopicArn",
1532
+ "Message",
1533
+ "MessageAttributes",
1534
+ "MessageStructure",
1535
+ "Subject",
1536
+ "MessageId",
1537
+ ]
1538
+
1539
+ @route(PLATFORM_ENDPOINT_MSGS_ENDPOINT, methods=["GET"])
1540
+ @count_usage
1541
+ def on_get(self, request: Request):
1542
+ filter_endpoint_arn = request.args.get("endpointArn")
1543
+ account_id = (
1544
+ request.args.get("accountId", DEFAULT_AWS_ACCOUNT_ID)
1545
+ if not filter_endpoint_arn
1546
+ else extract_account_id_from_arn(filter_endpoint_arn)
1547
+ )
1548
+ region = (
1549
+ request.args.get("region", AWS_REGION_US_EAST_1)
1550
+ if not filter_endpoint_arn
1551
+ else extract_region_from_arn(filter_endpoint_arn)
1552
+ )
1553
+ store: SnsStore = sns_stores[account_id][region]
1554
+ if filter_endpoint_arn:
1555
+ messages = store.platform_endpoint_messages.get(filter_endpoint_arn, [])
1556
+ messages = _format_messages(messages, self._PAYLOAD_FIELDS)
1557
+ return {
1558
+ "platform_endpoint_messages": {filter_endpoint_arn: messages},
1559
+ "region": region,
1560
+ }
1561
+
1562
+ platform_endpoint_messages = {
1563
+ endpoint_arn: _format_messages(messages, self._PAYLOAD_FIELDS)
1564
+ for endpoint_arn, messages in store.platform_endpoint_messages.items()
1565
+ }
1566
+ return {
1567
+ "platform_endpoint_messages": platform_endpoint_messages,
1568
+ "region": region,
1569
+ }
1570
+
1571
+ @route(PLATFORM_ENDPOINT_MSGS_ENDPOINT, methods=["DELETE"])
1572
+ @count_usage
1573
+ def on_delete(self, request: Request) -> Response:
1574
+ filter_endpoint_arn = request.args.get("endpointArn")
1575
+ account_id = (
1576
+ request.args.get("accountId", DEFAULT_AWS_ACCOUNT_ID)
1577
+ if not filter_endpoint_arn
1578
+ else extract_account_id_from_arn(filter_endpoint_arn)
1579
+ )
1580
+ region = (
1581
+ request.args.get("region", AWS_REGION_US_EAST_1)
1582
+ if not filter_endpoint_arn
1583
+ else extract_region_from_arn(filter_endpoint_arn)
1584
+ )
1585
+ store: SnsStore = sns_stores[account_id][region]
1586
+ if filter_endpoint_arn:
1587
+ store.platform_endpoint_messages.pop(filter_endpoint_arn, None)
1588
+ return Response("", status=204)
1589
+
1590
+ store.platform_endpoint_messages.clear()
1591
+ return Response("", status=204)
1592
+
1593
+
1594
+ def register_sns_api_resource(router: Router):
1595
+ """Register the retrospection endpoints as internal LocalStack endpoints."""
1596
+ router.add(SNSServicePlatformEndpointMessagesApiResource())
1597
+ router.add(SNSServiceSMSMessagesApiResource())
1598
+ router.add(SNSServiceSubscriptionTokenApiResource())
1599
+
1600
+
1601
+ def _format_messages(sent_messages: list[dict[str, str]], validated_keys: list[str]):
1602
+ """
1603
+ This method format the messages to be more readable and undo the format change that was needed for Moto
1604
+ Should be removed once we refactor SNS.
1605
+ """
1606
+ formatted_messages = []
1607
+ for sent_message in sent_messages:
1608
+ msg = {
1609
+ key: json.dumps(value)
1610
+ if key == "Message" and sent_message.get("MessageStructure") == "json"
1611
+ else value
1612
+ for key, value in sent_message.items()
1613
+ if key in validated_keys
1614
+ }
1615
+ formatted_messages.append(msg)
1616
+
1617
+ return formatted_messages
1618
+
1619
+
1620
+ class SNSServiceSMSMessagesApiResource(SNSInternalResource):
1621
+ resource_type = "sms-message"
1622
+ """Provides a REST API for retrospective access to SMS messages sent via SNS.
1623
+
1624
+ This is registered as a LocalStack internal HTTP resource.
1625
+
1626
+ This endpoint accepts:
1627
+ - GET param `accountId`: selector for AWS account. If not specified, return fallback `000000000000` test ID
1628
+ - GET param `region`: selector for AWS `region`. If not specified, return default "us-east-1"
1629
+ - GET param `phoneNumber`: filter for `phoneNumber` resource in SNS
1630
+ """
1631
+
1632
+ _PAYLOAD_FIELDS = [
1633
+ "PhoneNumber",
1634
+ "TopicArn",
1635
+ "SubscriptionArn",
1636
+ "MessageId",
1637
+ "Message",
1638
+ "MessageAttributes",
1639
+ "MessageStructure",
1640
+ "Subject",
1641
+ ]
1642
+
1643
+ @route(SMS_MSGS_ENDPOINT, methods=["GET"])
1644
+ @count_usage
1645
+ def on_get(self, request: Request):
1646
+ account_id = request.args.get("accountId", DEFAULT_AWS_ACCOUNT_ID)
1647
+ region = request.args.get("region", AWS_REGION_US_EAST_1)
1648
+ filter_phone_number = request.args.get("phoneNumber")
1649
+ store: SnsStore = sns_stores[account_id][region]
1650
+ if filter_phone_number:
1651
+ messages = [
1652
+ m for m in store.sms_messages if m.get("PhoneNumber") == filter_phone_number
1653
+ ]
1654
+ messages = _format_messages(messages, self._PAYLOAD_FIELDS)
1655
+ return {
1656
+ "sms_messages": {filter_phone_number: messages},
1657
+ "region": region,
1658
+ }
1659
+
1660
+ sms_messages = {}
1661
+
1662
+ for m in _format_messages(store.sms_messages, self._PAYLOAD_FIELDS):
1663
+ sms_messages.setdefault(m.get("PhoneNumber"), []).append(m)
1664
+
1665
+ return {
1666
+ "sms_messages": sms_messages,
1667
+ "region": region,
1668
+ }
1669
+
1670
+ @route(SMS_MSGS_ENDPOINT, methods=["DELETE"])
1671
+ @count_usage
1672
+ def on_delete(self, request: Request) -> Response:
1673
+ account_id = request.args.get("accountId", DEFAULT_AWS_ACCOUNT_ID)
1674
+ region = request.args.get("region", AWS_REGION_US_EAST_1)
1675
+ filter_phone_number = request.args.get("phoneNumber")
1676
+ store: SnsStore = sns_stores[account_id][region]
1677
+ if filter_phone_number:
1678
+ store.sms_messages = [
1679
+ m for m in store.sms_messages if m.get("PhoneNumber") != filter_phone_number
1680
+ ]
1681
+ return Response("", status=204)
1682
+
1683
+ store.sms_messages.clear()
1684
+ return Response("", status=204)
1685
+
1686
+
1687
+ class SNSServiceSubscriptionTokenApiResource(SNSInternalResource):
1688
+ resource_type = "subscription-token"
1689
+ """Provides a REST API for retrospective access to Subscription Confirmation Tokens to confirm subscriptions.
1690
+ Those are not sent for email, and sometimes inaccessible when working with external HTTPS endpoint which won't be
1691
+ able to reach your local host.
1692
+
1693
+ This is registered as a LocalStack internal HTTP resource.
1694
+
1695
+ This endpoint has the following parameter:
1696
+ - GET `subscription_arn`: `subscriptionArn`resource in SNS for which you want the SubscriptionToken
1697
+ """
1698
+
1699
+ @route(f"{SUBSCRIPTION_TOKENS_ENDPOINT}/<path:subscription_arn>", methods=["GET"])
1700
+ @count_usage
1701
+ def on_get(self, _request: Request, subscription_arn: str):
1702
+ try:
1703
+ parsed_arn = parse_arn(subscription_arn)
1704
+ except InvalidArnException:
1705
+ response = Response("", 400)
1706
+ response.set_json(
1707
+ {
1708
+ "error": "The provided SubscriptionARN is invalid",
1709
+ "subscription_arn": subscription_arn,
1710
+ }
1711
+ )
1712
+ return response
1713
+
1714
+ store: SnsStore = sns_stores[parsed_arn["account"]][parsed_arn["region"]]
1715
+
1716
+ for token, sub_arn in store.subscription_tokens.items():
1717
+ if sub_arn == subscription_arn:
1718
+ return {
1719
+ "subscription_token": token,
1720
+ "subscription_arn": subscription_arn,
1721
+ }
1722
+
1723
+ response = Response("", 404)
1724
+ response.set_json(
1725
+ {
1726
+ "error": "The provided SubscriptionARN is not found",
1727
+ "subscription_arn": subscription_arn,
1728
+ }
1729
+ )
1730
+ return response