[AWS CloudFormation] Amazon SNS プッシュ通知環境構築

# 前提

- 通知先デバイスのOSはAndroid
- 通知先デバイスは1台　※複数台に通知する場合はトピックを使用
- Mobileアプリの環境構築、実装については本記事では言及しない

# 構成要素

Amazon SNSを使用してプッシュ通知を行う場合、以下の構成となる

![image-20210123091531916.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/695430/1fe4fa77-422d-b892-76be-9069fecbdb7b.png)

# 準備

Amazon SNSからFCM経由でMolblieアプリにプッシュ通知を行うためには、
通知先のデバイスを特定するためのデバイストークンと、
Firebaseのプロジェクトを特定するためのサーバーキーが必要となる
そのため以下の準備を行う

- Firebaseに任意のプロジェクトを作成

- Firebase Cloud MessagingにMoblieアプリを登録

- Firebaseの以下からサーバーキーを取得する

![image-20210122170324954.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/695430/39c3310c-0af1-67d6-3fdb-33375b24a0be.png)

- Mobileアプリからデバイストークンを取得 

    ※取得方法については参考記事等を参照
    ※デバイストークンはMobileアプリのインストール毎に変化する

# マネジメントコンソールでの環境構築

プッシュ通知から、
プラットフォームアプリケーションの作成をクリック

![1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/695430/3ef1da4e-838a-d277-0099-80f9ce7c02c3.png)

任意のアプリケーション名を入力
プッシュ通知プラットフォームはFCMを選択
APIキーに準備で取得したサーバーキーを入力
プラットフォームアプリケーションをクリック

![2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/695430/dc9ddedb-278c-7afe-5e5f-281c0190530e.png)

アプリケーションエンドポイントの作成をクリック

![3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/695430/df5b2f42-a7e3-0e5b-2436-73f25c231348.png)

デバイストークンに準備で取得したデバイストークンを入力
アプリケーションエンドポイントの作成をクリック

![4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/695430/538d2293-e9f4-460d-f35c-cd157ec6fb47.png)

マネジメントコンソールからのプッシュ通知テストでは、
配信プロトコルごとにカスタムペイロードを選択

![5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/695430/ccede98c-1c1f-7b89-54aa-b55e8141e8f2.png)

メッセージの内容は、
Mobileアプリがバックグラウンドでもプッシュ通知するためには以下のようなフォーマットとなる
※`android_channel_id`はMobileアプリの実装に合わせる

```json
{
  "GCM": "{ \"notification\": { \"body\": \"message body\", \"title\": \"message title \", \"sound\":\"default\" ,\"click_action\": \"FLUTTER_NOTIFICATION_CLICK\", \"android_channel_id\":\"1\"} , \"data\" : {\"key1\" : \"value\", \"key2\" : \"value\" } }"
}
```

# CloudFormationでの環境構築

[Amazon Simple Notification Service リソースタイプのリファレンス](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/AWS_SNS.html)からわかるように、
マネジメントコンソールで作成したプラットフォームアプリケーション、アプリケーションエンドポイントはCloudFormationに対応していない
今回は、カスタムリソースを使用してプラットフォームアプリケーションを作成し、
Lambdaにてアプリケーションエンドポイントを追加する

カスタムリソースにはcfn-responseモジュールが必要となる、cfn-responseモジュールについては以下を参照
[cfn-responseモジュール](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/cfn-lambda-function-code-cfnresponsemodule.html)
今回は、cfn-responseモジュールをLayer化して使用している



### カスタムリソースによるプラットフォームアプリケーション作成

以下、プラットフォームアプリケーションを作成するためのテンプレート

```yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  child stack (SNS)

Globals:
  Function:
    Timeout: 3
    Runtime: python3.8
    Handler: function.lambda_handler

Parameters:
  StackName:
    Type: String
  CfnResponseArn: # cfn-responseモジュールLayer
    Type: String
  ApiKey: # プラットフォームアプリケーション生成に必要なAPIキー（サーバーキー）
    Type: String

Resources:
  CreatePlatformApplicationRole: # カスタムリソース生成時に実行されるLambdaに付与するロール
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Path: /
      Policies:
        - PolicyName: CreatePlatformApplicationPolicy # SNS操作用ポリシー
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: sns:*
                Resource: '*'

  CreatePlatformApplicationFunction: # カスタムリソース生成時に実行されるLambda
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: !Sub ${StackName}_CreatePlatformApplicationFunction
      Role: !GetAtt CreatePlatformApplicationRole.Arn
      CodeUri: create-platform-application/
      Layers:
        - !Ref CfnResponseArn

  # プラットフォームアプリケーションを生成するカスタムリソース
  CreatePlatformApplication:
    Type: Custom::CreatePlatformApplication
    Properties:
      ServiceToken: !GetAtt 'CreatePlatformApplicationFunction.Arn'　# リソースを生成するLambda Arn
      PlatformApplicationName: !Sub ${StackName}_sample # プラットフォームアプリケーション名
      PlatformApplicationArn: !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:app/GCM/${StackName}_sample # プラットフォームアプリケーションArn
      ApiKey: !Ref ApiKey # プラットフォームアプリケーション生成に必要なAPIキー（サーバーキー）

Outputs:
  PlatformApplicationArn: # 生成したプラットフォームアプリケーションを他テンプレートで使用できるようにOutput
    Value: !Sub arn:aws:sns:${AWS::Region}:${AWS::AccountId}:app/GCM/${StackName}_sample
```

以下、カスタムリソース生成時に実行されるLambda

```python
import json
import boto3
from cfnresponse import CfnResponse

def lambda_handler(event, context):
  try:
    sns = boto3.client('sns')

    if event['RequestType'] == 'Create': # リソース生成時
      # プラットフォームアプリケーションの生成
      response = sns.create_platform_application(
        Name = event['ResourceProperties']['PlatformApplicationName'],
        Platform = 'GCM',
        Attributes = {
          'PlatformPrincipal': '',
          'PlatformCredential': event['ResourceProperties']['ApiKey']
        }
      )
      CfnResponse.send(event, context, CfnResponse.SUCCESS, {})

    elif event['RequestType'] == 'Delete': # リソース削除時
      # プラットフォームアプリケーションの削除
      response = sns.delete_platform_application(
        PlatformApplicationArn = event['ResourceProperties']['PlatformApplicationArn']
      )
      CfnResponse.send(event, context, CfnResponse.SUCCESS, {})

    else:
      CfnResponse.send(event, context, CfnResponse.SUCCESS, {})

  except Exception as e:
    CfnResponse.send(event, context, CfnResponse.FAILED, {})
```



### Lambdaによるアプリケーションエンドポイントの追加

以下、アプリケーションエンドポイントを追加するLambdaとそのテンプレート

```yaml
Parameters:
  StackName:
    Type: String
  PlatformApplicationArn:
    Type: String

Resources:
  SetEndpointFunction:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: !Sub '${StackName}_SetEndpointFunction'
      CodeUri: set-endpoint/
      Environment:
        Variables:
          PlatformApplicationArn: !Ref PlatformApplicationArn # プラットフォームアプリケーションArn
      Policies:
        - Version: '2012-10-17'
          Statement:
          - Effect: Allow
            Action: sns:*
            Resource: '*'
```

```python
import os
import json
import boto3

platformApplicationArn = os.getenv('PlatformApplicationArn')
sns = boto3.client('sns')

def add_endpoint(token):
  # アプリケーションエンドポイントの追加
  # ※既存のtokenの場合はendpoint（Arn）返却のみされる
  endpoint = sns.create_platform_endpoint(
    PlatformApplicationArn = platformApplicationArn,
    Token = token
  )
```



### プッシュ通知を行うLambda

以下、プッシュ通知のLambda実装は以下のようになる

```python
import os
import boto3
import json

sns = boto3.client('sns')
platformApplicationArn = os.getenv('PlatformApplicationArn')

# デバイストークンから通知先のendpoint（Arn）を取得する
def get_targetEndpoint(deviceToken):
  try:
    # ※既存のtokenの場合はendpoint（Arn）返却のみされる
    endpoint = sns.create_platform_endpoint(
      PlatformApplicationArn = platformApplicationArn,
      Token = deviceToken
    )
  except:
    raise ValueError(f'get_targetEndpoint error')
  return endpoint['EndpointArn']

def publish_notification(deviceToken):
  try:
    payload = {
  	    'notification': {
        'body': 'message body',
        'title': 'message title',
        'click_action': 'FLUTTER_NOTIFICATION_CLICK',
        'android_channel_id': '1'
      },
      'data': {
        'key1': value1,
        'key2': value2
      }
    }
    message = json.dumps({
      "GCM": json.dumps(payload)
    })
    response = sns.publish(
      TargetArn = get_targetEndpoint(deviceToken),
      Message = message,
      MessageStructure = 'json'
    )
  except:
    raise ValueError(f'publish_notification error')
```

# 参考

- [AWS SNSからAndroidにプッシュ通知するためにやったこと、ハマったこと](https://qiita.com/nashitake/items/4724527c5c6fef4427d2)
- [Amazon Simple Notification Service リソースタイプのリファレンス](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/AWS_SNS.html)
- [cfn-responseモジュール](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/cfn-lambda-function-code-cfnresponsemodule.html)