Skip to content
This repository was archived by the owner on Mar 23, 2026. It is now read-only.

Commit d87d075

Browse files
authored
Sns:v2 platform application crud (#13312)
1 parent 23de0f8 commit d87d075

8 files changed

Lines changed: 869 additions & 7 deletions

File tree

localstack-core/localstack/services/sns/constants.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import re
22
from string import ascii_letters, digits
3+
from typing import get_args
4+
5+
from localstack.services.sns.v2.models import SnsApplicationPlatforms
36

47
SNS_PROTOCOLS = [
58
"http",
@@ -40,3 +43,5 @@
4043

4144
DUMMY_SUBSCRIPTION_PRINCIPAL = "arn:{partition}:iam::{account_id}:user/DummySNSPrincipal"
4245
E164_REGEX = re.compile(r"^\+?[1-9]\d{1,14}$")
46+
47+
VALID_APPLICATION_PLATFORMS = list(get_args(SnsApplicationPlatforms))

localstack-core/localstack/services/sns/v2/models.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from localstack.aws.api.sns import (
88
MessageAttributeMap,
9+
PlatformApplication,
910
PublishBatchRequestEntry,
1011
TopicAttributesMap,
1112
subscriptionARN,
@@ -36,6 +37,8 @@ class Topic(TypedDict, total=True):
3637
SnsApplicationPlatforms = Literal[
3738
"APNS", "APNS_SANDBOX", "ADM", "FCM", "Baidu", "GCM", "MPNS", "WNS"
3839
]
40+
41+
3942
SMS_ATTRIBUTE_NAMES = [
4043
"DeliveryStatusIAMRole",
4144
"DeliveryStatusSuccessSamplingRate",
@@ -152,6 +155,9 @@ class SnsStore(BaseStore):
152155
# maps confirmation token to subscription ARN
153156
subscription_tokens: dict[str, str] = LocalAttribute(default=dict)
154157

158+
# maps platform application arns to platform applications
159+
platform_applications: dict[str, PlatformApplication] = LocalAttribute(default=dict)
160+
155161
# topic/subscription independent default values for sending sms messages
156162
sms_attributes: dict[str, str] = LocalAttribute(default=dict)
157163

localstack-core/localstack/services/sns/v2/provider.py

Lines changed: 159 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,27 @@
66

77
from botocore.utils import InvalidArnException
88

9-
from localstack.aws.api import RequestContext
9+
from localstack.aws.api import CommonServiceException, RequestContext
1010
from localstack.aws.api.sns import (
1111
AmazonResourceName,
1212
ConfirmSubscriptionResponse,
13+
CreatePlatformApplicationResponse,
1314
CreateTopicResponse,
15+
GetPlatformApplicationAttributesResponse,
1416
GetSMSAttributesResponse,
1517
GetSubscriptionAttributesResponse,
1618
GetTopicAttributesResponse,
1719
InvalidParameterException,
20+
ListEndpointsByPlatformApplicationResponse,
21+
ListPlatformApplicationsResponse,
1822
ListString,
1923
ListSubscriptionsByTopicResponse,
2024
ListSubscriptionsResponse,
2125
ListTagsForResourceResponse,
2226
ListTopicsResponse,
2327
MapStringToString,
2428
NotFoundException,
29+
PlatformApplication,
2530
SetSMSAttributesResponse,
2631
SnsApi,
2732
String,
@@ -45,7 +50,10 @@
4550
)
4651
from localstack.services.sns import constants as sns_constants
4752
from localstack.services.sns.certificate import SNS_SERVER_CERT
48-
from localstack.services.sns.constants import DUMMY_SUBSCRIPTION_PRINCIPAL
53+
from localstack.services.sns.constants import (
54+
DUMMY_SUBSCRIPTION_PRINCIPAL,
55+
VALID_APPLICATION_PLATFORMS,
56+
)
4957
from localstack.services.sns.filter import FilterPolicyValidator
5058
from localstack.services.sns.publisher import PublishDispatcher, SnsPublishContext
5159
from localstack.services.sns.v2.models import (
@@ -65,10 +73,16 @@
6573
get_next_page_token_from_arn,
6674
get_region_from_subscription_token,
6775
is_valid_e164_number,
76+
parse_and_validate_platform_application_arn,
6877
parse_and_validate_topic_arn,
6978
validate_subscription_attribute,
7079
)
71-
from localstack.utils.aws.arns import get_partition, parse_arn, sns_topic_arn
80+
from localstack.utils.aws.arns import (
81+
get_partition,
82+
parse_arn,
83+
sns_platform_application_arn,
84+
sns_topic_arn,
85+
)
7286
from localstack.utils.collections import PaginatedList, select_from_typed_dict
7387

7488
# set up logger
@@ -535,6 +549,115 @@ def list_subscriptions_by_topic(
535549
response["NextToken"] = next_token
536550
return response
537551

552+
#
553+
# PlatformApplications
554+
#
555+
def create_platform_application(
556+
self,
557+
context: RequestContext,
558+
name: String,
559+
platform: String,
560+
attributes: MapStringToString,
561+
**kwargs,
562+
) -> CreatePlatformApplicationResponse:
563+
_validate_platform_application_name(name)
564+
if platform not in VALID_APPLICATION_PLATFORMS:
565+
raise InvalidParameterException(
566+
f"Invalid parameter: Platform Reason: {platform} is not supported"
567+
)
568+
569+
_validate_platform_application_attributes(attributes)
570+
571+
# attribute validation specific to create_platform_application
572+
if "PlatformCredential" in attributes and "PlatformPrincipal" not in attributes:
573+
raise InvalidParameterException(
574+
"Invalid parameter: Attributes Reason: PlatformCredential attribute provided without PlatformPrincipal"
575+
)
576+
577+
elif "PlatformPrincipal" in attributes and "PlatformCredential" not in attributes:
578+
raise InvalidParameterException(
579+
"Invalid parameter: Attributes Reason: PlatformPrincipal attribute provided without PlatformCredential"
580+
)
581+
582+
store = self.get_store(context.account_id, context.region)
583+
# We are not validating the access data here like AWS does (against ADM and the like)
584+
attributes.pop("PlatformPrincipal")
585+
attributes.pop("PlatformCredential")
586+
_attributes = {"Enabled": "true"}
587+
_attributes.update(attributes)
588+
application_arn = sns_platform_application_arn(
589+
platform_application_name=name,
590+
platform=platform,
591+
account_id=context.account_id,
592+
region_name=context.region,
593+
)
594+
platform_application = PlatformApplication(
595+
PlatformApplicationArn=application_arn, Attributes=_attributes
596+
)
597+
store.platform_applications[application_arn] = platform_application
598+
return CreatePlatformApplicationResponse(**platform_application)
599+
600+
def delete_platform_application(
601+
self, context: RequestContext, platform_application_arn: String, **kwargs
602+
) -> None:
603+
store = self.get_store(context.account_id, context.region)
604+
store.platform_applications.pop(platform_application_arn, None)
605+
606+
def list_platform_applications(
607+
self, context: RequestContext, next_token: String | None = None, **kwargs
608+
) -> ListPlatformApplicationsResponse:
609+
store = self.get_store(context.account_id, context.region)
610+
platform_applications = store.platform_applications.values()
611+
paginated_applications = PaginatedList(platform_applications)
612+
page, token = paginated_applications.get_page(
613+
token_generator=lambda x: get_next_page_token_from_arn(x["PlatformApplicationArn"]),
614+
page_size=100,
615+
next_token=next_token,
616+
)
617+
618+
response = ListPlatformApplicationsResponse(PlatformApplications=page)
619+
if token:
620+
response["NextToken"] = token
621+
return response
622+
623+
def get_platform_application_attributes(
624+
self, context: RequestContext, platform_application_arn: String, **kwargs
625+
) -> GetPlatformApplicationAttributesResponse:
626+
platform_application = self._get_platform_application(platform_application_arn, context)
627+
attributes = platform_application["Attributes"]
628+
return GetPlatformApplicationAttributesResponse(Attributes=attributes)
629+
630+
def set_platform_application_attributes(
631+
self,
632+
context: RequestContext,
633+
platform_application_arn: String,
634+
attributes: MapStringToString,
635+
**kwargs,
636+
) -> None:
637+
parse_and_validate_platform_application_arn(platform_application_arn)
638+
_validate_platform_application_attributes(attributes)
639+
640+
platform_application = self._get_platform_application(platform_application_arn, context)
641+
platform_application["Attributes"].update(attributes)
642+
643+
#
644+
# Platform Endpoints
645+
#
646+
647+
def list_endpoints_by_platform_application(
648+
self,
649+
context: RequestContext,
650+
platform_application_arn: String,
651+
next_token: String | None = None,
652+
**kwargs,
653+
) -> ListEndpointsByPlatformApplicationResponse:
654+
# TODO: stub so cleanup fixture won't fail
655+
return ListEndpointsByPlatformApplicationResponse(Endpoints=[])
656+
657+
#
658+
# Sms operations
659+
#
660+
538661
def set_sms_attributes(
539662
self, context: RequestContext, attributes: MapStringToString, **kwargs
540663
) -> SetSMSAttributesResponse:
@@ -606,6 +729,17 @@ def _get_topic(arn: str, context: RequestContext) -> Topic:
606729
except KeyError:
607730
raise NotFoundException("Topic does not exist")
608731

732+
@staticmethod
733+
def _get_platform_application(
734+
platform_application_arn: str, context: RequestContext
735+
) -> PlatformApplication:
736+
parse_and_validate_platform_application_arn(platform_application_arn)
737+
try:
738+
store = SnsProvider.get_store(context.account_id, context.region)
739+
return store.platform_applications[platform_application_arn]
740+
except KeyError:
741+
raise NotFoundException("PlatformApplication does not exist")
742+
609743

610744
def _create_topic(name: str, attributes: dict, context: RequestContext) -> Topic:
611745
topic_arn = sns_topic_arn(
@@ -673,6 +807,28 @@ def _create_default_topic_policy(topic: Topic, context: RequestContext) -> str:
673807
)
674808

675809

810+
def _validate_platform_application_name(name: str) -> None:
811+
reason = ""
812+
if not name:
813+
reason = "cannot be empty"
814+
elif not re.match(r"^.{0,256}$", name):
815+
reason = "must be at most 256 characters long"
816+
elif not re.match(r"^[A-Za-z0-9._-]+$", name):
817+
reason = "must contain only characters 'a'-'z', 'A'-'Z', '0'-'9', '_', '-', and '.'"
818+
819+
if reason:
820+
raise InvalidParameterException(f"Invalid parameter: {name} Reason: {reason}")
821+
822+
823+
def _validate_platform_application_attributes(attributes: dict) -> None:
824+
if not attributes:
825+
raise CommonServiceException(
826+
code="ValidationError",
827+
message="1 validation error detected: Value null at 'attributes' failed to satisfy constraint: Member must not be null",
828+
sender_fault=True,
829+
)
830+
831+
676832
def _validate_sms_attributes(attributes: dict) -> None:
677833
for k, v in attributes.items():
678834
if k not in SMS_ATTRIBUTE_NAMES:

localstack-core/localstack/services/sns/v2/utils.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,21 @@
1111

1212

1313
def parse_and_validate_topic_arn(topic_arn: str | None) -> ArnData:
14-
topic_arn = topic_arn or ""
14+
return _parse_and_validate_arn(topic_arn, "Topic")
15+
16+
17+
def parse_and_validate_platform_application_arn(platform_application_arn: str | None) -> ArnData:
18+
return _parse_and_validate_arn(platform_application_arn, "PlatformApplication")
19+
20+
21+
def _parse_and_validate_arn(arn: str | None, resource_type: str) -> ArnData:
22+
arn = arn or ""
1523
try:
16-
return parse_arn(topic_arn)
24+
return parse_arn(arn)
1725
except InvalidArnException:
18-
count = len(topic_arn.split(":"))
26+
count = len(arn.split(":"))
1927
raise InvalidParameterException(
20-
f"Invalid parameter: TopicArn Reason: An ARN must have at least 6 elements, not {count}"
28+
f"Invalid parameter: {resource_type}Arn Reason: An ARN must have at least 6 elements, not {count}"
2129
)
2230

2331

localstack-core/localstack/utils/aws/arns.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,12 @@ def sns_topic_arn(topic_name: str, account_id: str, region_name: str) -> str:
476476
return f"arn:{get_partition(region_name)}:sns:{region_name}:{account_id}:{topic_name}"
477477

478478

479+
def sns_platform_application_arn(
480+
platform_application_name: str, platform: str, account_id: str, region_name: str
481+
) -> str:
482+
return f"arn:{get_partition(region_name)}:sns:{region_name}:{account_id}:app/{platform}/{platform_application_name}"
483+
484+
479485
#
480486
# ECR
481487
#

0 commit comments

Comments
 (0)