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.
- localstack/aws/api/acm/__init__.py +122 -122
- localstack/aws/api/apigateway/__init__.py +604 -561
- localstack/aws/api/cloudcontrol/__init__.py +63 -63
- localstack/aws/api/cloudformation/__init__.py +1201 -969
- localstack/aws/api/cloudwatch/__init__.py +375 -375
- localstack/aws/api/config/__init__.py +784 -786
- localstack/aws/api/dynamodb/__init__.py +753 -759
- localstack/aws/api/dynamodbstreams/__init__.py +74 -74
- localstack/aws/api/ec2/__init__.py +10062 -8826
- localstack/aws/api/es/__init__.py +453 -453
- localstack/aws/api/events/__init__.py +552 -552
- localstack/aws/api/firehose/__init__.py +541 -543
- localstack/aws/api/iam/__init__.py +866 -572
- localstack/aws/api/kinesis/__init__.py +235 -147
- localstack/aws/api/kms/__init__.py +341 -336
- localstack/aws/api/lambda_/__init__.py +974 -621
- localstack/aws/api/logs/__init__.py +988 -675
- localstack/aws/api/opensearch/__init__.py +903 -785
- localstack/aws/api/pipes/__init__.py +336 -336
- localstack/aws/api/redshift/__init__.py +1257 -1166
- localstack/aws/api/resource_groups/__init__.py +175 -175
- localstack/aws/api/resourcegroupstaggingapi/__init__.py +103 -67
- localstack/aws/api/route53/__init__.py +296 -254
- localstack/aws/api/route53resolver/__init__.py +397 -396
- localstack/aws/api/s3/__init__.py +1412 -1349
- localstack/aws/api/s3control/__init__.py +594 -594
- localstack/aws/api/scheduler/__init__.py +118 -118
- localstack/aws/api/secretsmanager/__init__.py +221 -216
- localstack/aws/api/ses/__init__.py +227 -227
- localstack/aws/api/sns/__init__.py +115 -115
- localstack/aws/api/sqs/__init__.py +100 -100
- localstack/aws/api/ssm/__init__.py +1977 -1971
- localstack/aws/api/stepfunctions/__init__.py +375 -333
- localstack/aws/api/sts/__init__.py +142 -66
- localstack/aws/api/support/__init__.py +112 -112
- localstack/aws/api/swf/__init__.py +378 -386
- localstack/aws/api/transcribe/__init__.py +425 -425
- localstack/aws/handlers/logging.py +8 -4
- localstack/aws/handlers/service.py +22 -3
- localstack/aws/protocol/parser.py +1 -1
- localstack/aws/protocol/serializer.py +1 -1
- localstack/aws/scaffold.py +15 -17
- localstack/cli/localstack.py +6 -1
- localstack/deprecations.py +0 -6
- localstack/dev/kubernetes/__main__.py +38 -3
- localstack/services/acm/provider.py +4 -0
- localstack/services/apigateway/helpers.py +5 -9
- localstack/services/apigateway/legacy/provider.py +60 -24
- localstack/services/apigateway/patches.py +0 -9
- localstack/services/cloudformation/engine/template_preparer.py +6 -2
- localstack/services/cloudformation/engine/v2/change_set_model_preproc.py +12 -0
- localstack/services/cloudformation/provider.py +2 -2
- localstack/services/cloudformation/v2/provider.py +6 -6
- localstack/services/cloudwatch/provider.py +10 -3
- localstack/services/cloudwatch/provider_v2.py +6 -3
- localstack/services/configservice/provider.py +5 -1
- localstack/services/dynamodb/provider.py +1 -0
- localstack/services/dynamodb/v2/provider.py +1 -0
- localstack/services/dynamodbstreams/provider.py +6 -0
- localstack/services/dynamodbstreams/v2/provider.py +6 -0
- localstack/services/ec2/provider.py +6 -0
- localstack/services/es/provider.py +6 -0
- localstack/services/events/provider.py +4 -0
- localstack/services/events/v1/provider.py +9 -0
- localstack/services/firehose/provider.py +5 -0
- localstack/services/iam/provider.py +4 -0
- localstack/services/kinesis/packages.py +1 -1
- localstack/services/kms/models.py +44 -24
- localstack/services/kms/provider.py +97 -16
- localstack/services/lambda_/api_utils.py +40 -21
- localstack/services/lambda_/event_source_mapping/pollers/stream_poller.py +1 -1
- localstack/services/lambda_/invocation/assignment.py +4 -1
- localstack/services/lambda_/invocation/execution_environment.py +21 -2
- localstack/services/lambda_/invocation/lambda_models.py +27 -2
- localstack/services/lambda_/invocation/lambda_service.py +51 -3
- localstack/services/lambda_/invocation/models.py +9 -1
- localstack/services/lambda_/invocation/version_manager.py +18 -3
- localstack/services/lambda_/packages.py +1 -1
- localstack/services/lambda_/provider.py +240 -96
- localstack/services/lambda_/resource_providers/aws_lambda_function.py +33 -1
- localstack/services/lambda_/runtimes.py +10 -3
- localstack/services/logs/provider.py +45 -19
- localstack/services/opensearch/provider.py +53 -3
- localstack/services/resource_groups/provider.py +5 -1
- localstack/services/resourcegroupstaggingapi/provider.py +6 -1
- localstack/services/s3/provider.py +29 -16
- localstack/services/s3/utils.py +35 -14
- localstack/services/s3control/provider.py +101 -2
- localstack/services/s3control/validation.py +50 -0
- localstack/services/sns/constants.py +3 -1
- localstack/services/sns/publisher.py +15 -6
- localstack/services/sns/v2/models.py +30 -1
- localstack/services/sns/v2/provider.py +794 -31
- localstack/services/sns/v2/utils.py +20 -0
- localstack/services/sqs/models.py +37 -10
- localstack/services/stepfunctions/asl/component/common/path/result_path.py +1 -1
- localstack/services/stepfunctions/asl/component/state/state_execution/execute_state.py +0 -1
- localstack/services/stepfunctions/asl/component/state/state_execution/state_map/state_map.py +0 -1
- localstack/services/stepfunctions/asl/component/state/state_execution/state_task/lambda_eval_utils.py +8 -8
- localstack/services/stepfunctions/asl/component/state/state_execution/state_task/{mock_eval_utils.py → local_mock_eval_utils.py} +13 -9
- localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service.py +6 -6
- localstack/services/stepfunctions/asl/component/state/state_execution/state_task/service/state_task_service_callback.py +1 -1
- localstack/services/stepfunctions/asl/component/state/state_fail/state_fail.py +4 -0
- localstack/services/stepfunctions/asl/component/test_state/state/base_mock.py +118 -0
- localstack/services/stepfunctions/asl/component/test_state/state/common.py +82 -0
- localstack/services/stepfunctions/asl/component/test_state/state/execution.py +139 -0
- localstack/services/stepfunctions/asl/component/test_state/state/map.py +77 -0
- localstack/services/stepfunctions/asl/component/test_state/state/task.py +44 -0
- localstack/services/stepfunctions/asl/eval/environment.py +30 -22
- localstack/services/stepfunctions/asl/eval/states.py +1 -1
- localstack/services/stepfunctions/asl/eval/test_state/environment.py +49 -9
- localstack/services/stepfunctions/asl/eval/test_state/program_state.py +22 -0
- localstack/services/stepfunctions/asl/jsonata/jsonata.py +5 -1
- localstack/services/stepfunctions/asl/parse/preprocessor.py +67 -24
- localstack/services/stepfunctions/asl/parse/test_state/asl_parser.py +5 -4
- localstack/services/stepfunctions/asl/parse/test_state/preprocessor.py +222 -31
- localstack/services/stepfunctions/asl/static_analyser/test_state/test_state_analyser.py +170 -22
- localstack/services/stepfunctions/backend/execution.py +6 -6
- localstack/services/stepfunctions/backend/execution_worker.py +5 -5
- localstack/services/stepfunctions/backend/test_state/execution.py +36 -0
- localstack/services/stepfunctions/backend/test_state/execution_worker.py +33 -1
- localstack/services/stepfunctions/backend/test_state/test_state_mock.py +127 -0
- localstack/services/stepfunctions/local_mocking/__init__.py +9 -0
- localstack/services/stepfunctions/{mocking → local_mocking}/mock_config.py +24 -17
- localstack/services/stepfunctions/provider.py +78 -27
- localstack/services/stepfunctions/test_state/mock_config.py +47 -0
- localstack/testing/pytest/fixtures.py +28 -0
- localstack/testing/snapshots/transformer_utility.py +7 -0
- localstack/testing/testselection/matching.py +0 -1
- localstack/utils/analytics/publisher.py +37 -155
- localstack/utils/analytics/service_request_aggregator.py +6 -4
- localstack/utils/aws/arns.py +7 -0
- localstack/utils/aws/client_types.py +0 -8
- localstack/utils/batching.py +258 -0
- localstack/utils/catalog/catalog_loader.py +111 -3
- localstack/utils/collections.py +23 -11
- localstack/utils/crypto.py +109 -0
- localstack/version.py +2 -2
- {localstack_core-4.10.1.dev7.dist-info → localstack_core-4.11.2.dev14.dist-info}/METADATA +7 -6
- {localstack_core-4.10.1.dev7.dist-info → localstack_core-4.11.2.dev14.dist-info}/RECORD +149 -141
- localstack_core-4.11.2.dev14.dist-info/plux.json +1 -0
- localstack/services/stepfunctions/mocking/__init__.py +0 -0
- localstack/utils/batch_policy.py +0 -124
- localstack_core-4.10.1.dev7.dist-info/plux.json +0 -1
- /localstack/services/stepfunctions/{mocking → local_mocking}/mock_config_file.py +0 -0
- {localstack_core-4.10.1.dev7.data → localstack_core-4.11.2.dev14.data}/scripts/localstack +0 -0
- {localstack_core-4.10.1.dev7.data → localstack_core-4.11.2.dev14.data}/scripts/localstack-supervisor +0 -0
- {localstack_core-4.10.1.dev7.data → localstack_core-4.11.2.dev14.data}/scripts/localstack.bat +0 -0
- {localstack_core-4.10.1.dev7.dist-info → localstack_core-4.11.2.dev14.dist-info}/WHEEL +0 -0
- {localstack_core-4.10.1.dev7.dist-info → localstack_core-4.11.2.dev14.dist-info}/entry_points.txt +0 -0
- {localstack_core-4.10.1.dev7.dist-info → localstack_core-4.11.2.dev14.dist-info}/licenses/LICENSE.txt +0 -0
- {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.
|
|
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
|
|
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
|
-
|
|
216
|
-
|
|
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
|
|
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:
|
|
241
|
-
|
|
242
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
595
|
-
|
|
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] =
|
|
598
|
-
|
|
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(
|
|
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
|
-
|
|
655
|
-
|
|
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
|