Search code examples
node.jsamazon-web-servicesaws-lambdaaws-api-gatewayamazon-sns

Is there a best approach to deploy an architecture to send SMS using a Microservice model?


We have a service within a Backend class, the service looks like:

// Setup AWS SNS
AWS.config.update({
    region: 'eu-west-1',
    accessKeyId: process.env.AWS_ACCESS_KEY_ID,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
});
var sns = new AWS.SNS();

var params = {
    Message: "SMS message test",
    MessageStructure: 'string',
    PhoneNumber: '0045xxxxxxxx',
    Subject: 'Alarm',
    MessageAttributes :{
        'AWS.SNS.SMS.SenderID': {
            'DataType': 'String',
            'StringValue': 'MySender'
        },
        'AWS.SNS.SMS.SMSType': 'Transactional'
    }
};

If we need to send an SMS, we simply call this service.

What is bad here is the following, and we know it:

  • We're using the Secret keys within the EC2. However, we're working on that for setting a role with the specific permissions to the instances.

  • Imagine we need to modify the way we are sending SMS, we would have to re-deploy the entire app just for that tiny part of our application.

  • Worst, imagine we have our application on AutoScaling. We would have to drop off all the instances just to update that tiny part of our application.

  • Another problem is, what if we have to use that service in other applications? The current approach leads to duplicate a service among applications.

  • Last, how to logging, monitoring, Etc.

We think there is a better approach to avoid these kind of problems, so you can see our approach for avoiding the above problems.


Solution

  • After hours of brainstorming we decided to use four basic services from AWS

    This architecture allows you to provide a Restful Endpoint which delivers a message to a specific receiver. This microservice could be executed from different parts of your application, device apps, Etc., so isn't tied to only one Backend purpose.

    ##The architecture looks as follow ###Detailed view enter image description here


    ###Simple view

    enter image description here


    #Explanation

    We are going to describe the process explaining step by step of the flow to deliver a SMS.

    1. A source needs to send a message to a specific telephone number, so the caller execute a POST request (/delivermessage) with the following payload to the API Gateway endpoing

    {
       "target": "554542121245",
       "type": "sms",
       "message": "Hello World!",
       "region": "us-east-1"
    }
    

    1. The API Gateway validates the API to grant access and send the received payload to the Lambda function.

    2. The Lambda function validates the received payload and execute the following:

      • Creates a SNS topic.
      • Creates a subscription using the received telephone number.
      • Subscribes it to the topic.
      • Publishes the message through that subscription.
      • Removes subscription.
      • Removes topic.
      • Returns back a success response to the caller:

    {
        "status": 200,
        "message": "The message has been sent!"
    }
               
    

    1. The API Gateway evaluates the response and send back the response to the caller.
      • The API Gateway has intelligence to check what kind of response was sent from the Lambda function.
      • For response starts with 412 means Precondition Failed.
      • For response starts with 500 means Internal server error.

    Lambda Code (NodeJs)

    var AWS = require('aws-sdk');
    
    /**
     * Entry function for this
     * Lambda.
     * 
     * This function delivers a message 
     * to a specific number.
     * 
     * First approach will only handle 
     * delivery type sms.
     */
    exports.handler = (event, context, callback) => {
        console.log(JSON.stringify(event));
    
        if (event.type === undefined || event.type === null || event.type === '' || event.type.trim() === '') {
            callback(get_response_message('Type of delivery is required.'), 412);
            return;
        }
       
        if (event.type.trim() !== 'sms') {
            callback(get_response_message('The available delivery type is \'sms\'.', 412));
            return;
        }
    
        if (event.type.trim() === 'sms' && (event.target === '' || isNaN(event.target))) {
            callback(get_response_message('The target must be a number.', 412));
            return;
        }
    
        deliver(event.target, event.message, event.region, callback);
    };
    
    /**
     * This function delivers a
     * message to a specific number.
     * 
     * The function will create a topic
     * from scratch to avoid any
     * clash among subscriptions.
     * 
     * @param number in context.
     * @param message that will be sent.
     * @param region in context.
     * @param cb a callback function to 
     *           return a response to the 
     *           caller of this service.
     */
    var deliver = (number, message, region, cb) => {
       var sns = new AWS.SNS({region: region});
       console.log(`${number} - ${region} - ${Date.now()}`);
       var params = { Name: `${number}_${region}_${Date.now()}` };
    
       sns.createTopic(params, function(err, tdata) {
         if (err) {
             console.log(err, err.stack);
             cb(get_response_message(err, 500));
         } else {
             console.log(tdata.TopicArn);
             sns.subscribe({
               Protocol: 'sms',
               TopicArn: tdata.TopicArn,
               Endpoint: number
           }, function(error, data) {
                if (error) {
                    //Rollback to the previous created services.
                    console.log(error, error.stack);
                    params = { TopicArn: tdata.TopicArn};
                    sns.deleteTopic(params, function() { cb(get_response_message(error, 500)); });
    
                    return;
                }
    
                console.log('subscribe data', data);
                var SubscriptionArn = data.SubscriptionArn;
    
                params = { TargetArn: tdata.TopicArn, Message: message, Subject: 'dummy' };
                sns.publish(params, function(err_publish, data) {
                   if (err_publish) {
                        console.log(err_publish, err_publish.stack);
                        //Rollback to the previous created services.
                        params = { TopicArn: tdata.TopicArn};
                        sns.deleteTopic(params, function() {
                            params = {SubscriptionArn: SubscriptionArn};
                            sns.unsubscribe(params, function() { cb(get_response_message(err_publish, 500)); });
                        });
    
                        return;
                   } else console.log('Sent message:', data.MessageId);
    
                   params = { SubscriptionArn: SubscriptionArn };
                   sns.unsubscribe(params, function(err, data) {
                      if (err) console.log('err when unsubscribe', err);
    
                      params = { TopicArn: tdata.TopicArn };
                      sns.deleteTopic(params, function(rterr, rtdata) {
                         if (rterr) {
                            console.log(rterr, rterr.stack);
                            cb(get_response_message(rterr, 500));
                         } else {
                            console.log(rtdata);
                            cb(null, get_response_message('Message has been sent!', 200));
                         }
                      });
                   });
               });
             });
          }
       });
    };
    
    /**
     * This function returns the response
     * message that will be sent to the 
     * caller of this service.
     */
    var get_response_message = (msg, status) => {
       if (status == 200) {
          return `{'status': ${status}, 'message': ${msg}}`;
       } else {
          return `${status} - ${msg}`;
       }
    };
    

    Cloudformation template

    This cloudformation template describes the whole set of services, API Gateway, Lambda function, Roles, Permissions, Usage plans for the API, API Key, Etc.

    For downloading click here

    {
        "AWSTemplateFormatVersion": "2010-09-09",
        "Description": "This template deploys the necessary resources for sending MSG through a API-Gateway endpoint, Lambda function and SNS service.",
        "Metadata": {
            "License": {
                "Description": "MIT license - Copyright (c) 2017"
            }
        },
        "Resources": {
            "LambdaRole": {
                "Type": "AWS::IAM::Role",
                "Properties": {
                    "AssumeRolePolicyDocument": {
                        "Version": "2012-10-17",
                        "Statement": [
                            {
                                "Effect": "Allow",
                                "Principal": {
                                    "Service": [
                                        "lambda.amazonaws.com"
                                    ]
                                },
                                "Action": [
                                    "sts:AssumeRole"
                                ]
                            }
                        ]
                    },
                    "Policies": [
                        {
                            "PolicyName": "LambdaSnsNotification",
                            "PolicyDocument": {
                                "Version": "2012-10-17",
                                "Statement": [
                                    {
                                        "Sid": "AllowSnsActions",
                                        "Effect": "Allow",
                                        "Action": [
                                            "sns:Publish",
                                            "sns:Subscribe",
                                            "sns:Unsubscribe",
                                            "sns:DeleteTopic",
                                            "sns:CreateTopic"
                                        ],
                                        "Resource": "*"
                                    }
                                ]
                            }
                        }
                    ]
                }
            },
            "LambdaFunctionMessageSNSTopic": {
                "Type": "AWS::Lambda::Function",
                "Properties": {
                    "Description": "Send message to a specific topic that will deliver MSG to a receiver.",
                    "Handler": "index.handler",
                    "MemorySize": 128,
                    "Role": {
                        "Fn::GetAtt": [
                            "LambdaRole",
                            "Arn"
                        ]
                    },
                    "Runtime": "nodejs6.10",
                    "Timeout": 60,
                    "Environment": {
                        "Variables": {
                            "sns_topic_arn": ""
                        }
                    },
                    "Code": {
                        "ZipFile": {
                            "Fn::Join": [
                                "\n",
                                [
                                    "var AWS = require('aws-sdk');",
                                    "",
                                    "/**",
                                    " * Entry function for this",
                                    " * Lambda.",
                                    " * ",
                                    " * This function delivers a message ",
                                    " * to a specific number.",
                                    " * ",
                                    " * First approach will only handle ",
                                    " * delivery type sms.",
                                    " */",
                                    "exports.handler = (event, context, callback) => {",
                                    "    console.log(JSON.stringify(event));",
                                    "",
                                    "    if (event.type === undefined || event.type === null || event.type === '' || event.type.trim() === '') {",
                                    "        callback(get_response_message('Type of delivery is required.'), 412);",
                                    "        return;",
                                    "    }",
                                    "   ",
                                    "    if (event.type.trim() !== 'sms') {",
                                    "        callback(get_response_message('The available delivery type is \'sms\'.', 412));",
                                    "        return;",
                                    "    }",
                                    "",
                                    "    if (event.type.trim() === 'sms' && (event.target === '' || isNaN(event.target))) {",
                                    "        callback(get_response_message('The target must be a number.', 412));",
                                    "        return;",
                                    "    }",
                                    "",
                                    "    deliver(event.target, event.message, event.region, callback);",
                                    "};",
                                    "",
                                    "/**",
                                    " * This function delivers a",
                                    " * message to a specific number.",
                                    " * ",
                                    " * The function will create a topic",
                                    " * from scratch to avoid any",
                                    " * clash among subscriptions.",
                                    " * ",
                                    " * @param number in context.",
                                    " * @param message that will be sent.",
                                    " * @param region in context.",
                                    " * @param cb a callback function to ",
                                    " *           return a response to the ",
                                    " *           caller of this service.",
                                    " */",
                                    "var deliver = (number, message, region, cb) => {",
                                    "   var sns = new AWS.SNS({region: region});",
                                    "   console.log(`${number} - ${region} - ${Date.now()}`);",
                                    "   var params = { Name: `${number}_${region}_${Date.now()}` };",
                                    "",
                                    "   sns.createTopic(params, function(err, tdata) {",
                                    "     if (err) {",
                                    "         console.log(err, err.stack);",
                                    "         cb(get_response_message(err, 500));",
                                    "     } else {",
                                    "         console.log(tdata.TopicArn);",
                                    "         sns.subscribe({",
                                    "           Protocol: 'sms',",
                                    "           TopicArn: tdata.TopicArn,",
                                    "           Endpoint: number",
                                    "       }, function(error, data) {",
                                    "            if (error) {",
                                    "               //Rollback to the previous created services.",
                                    "                console.log(error, error.stack);",
                                    "               params = { TopicArn: tdata.TopicArn};",
                                    "               sns.deleteTopic(params, function() { cb(get_response_message(error, 500)); });",
                                    "",
                                    "               return;",
                                    "            }",
                                    "",
                                    "            console.log('subscribe data', data);",
                                    "            var SubscriptionArn = data.SubscriptionArn;",
                                    "",
                                    "            params = { TargetArn: tdata.TopicArn, Message: message, Subject: 'dummy' };",
                                    "            sns.publish(params, function(err_publish, data) {",
                                    "               if (err_publish) {",
                                    "                    console.log(err_publish, err_publish.stack);",
                                    "                   //Rollback to the previous created services.",
                                    "                   params = { TopicArn: tdata.TopicArn};",
                                    "                   sns.deleteTopic(params, function() {",
                                    "                       params = {SubscriptionArn: SubscriptionArn};",
                                    "                       sns.unsubscribe(params, function() { cb(get_response_message(err_publish, 500)); });",
                                    "                   });",
                                    "",
                                    "                    return;",
                                    "               } else console.log('Sent message:', data.MessageId);",
                                    "",
                                    "               params = { SubscriptionArn: SubscriptionArn };",
                                    "               sns.unsubscribe(params, function(err, data) {",
                                    "                  if (err) console.log('err when unsubscribe', err);",
                                    "",
                                    "                  params = { TopicArn: tdata.TopicArn };",
                                    "                  sns.deleteTopic(params, function(rterr, rtdata) {",
                                    "                     if (rterr) {",
                                    "                        console.log(rterr, rterr.stack);",
                                    "                        cb(get_response_message(rterr, 500));",
                                    "                     } else {",
                                    "                        console.log(rtdata);",
                                    "                        cb(null, get_response_message('Message has been sent!', 200));",
                                    "                     }",
                                    "                  });",
                                    "               });",
                                    "           });",
                                    "         });",
                                    "      }",
                                    "   });",
                                    "};",
                                    "",
                                    "/**",
                                    " * This function returns the response",
                                    " * message that will be sent to the ",
                                    " * caller of this service.",
                                    " */",
                                    "var get_response_message = (msg, status) => {",
                                    "   if (status == 200) {",
                                    "      return `{'status': ${status}, 'message': ${msg}}`;",
                                    "   } else {",
                                    "      return `${status} - ${msg}`;",
                                    "   }",
                                    "};"
                                ]
                            ]
                        }
                    }
                }
            },
            "MSGGatewayRestApi": {
                "Type": "AWS::ApiGateway::RestApi",
                "Properties": {
                    "Name": "MSG RestApi",
                    "Description": "API used for sending MSG",
                    "FailOnWarnings": true
                }
            },
            "MSGGatewayRestApiUsagePlan": {
                "Type": "AWS::ApiGateway::UsagePlan",
                "Properties": {
                    "ApiStages": [
                        {
                            "ApiId": {
                                "Ref": "MSGGatewayRestApi"
                            },
                            "Stage": {
                                "Ref": "MSGGatewayRestApiStage"
                            }
                        }
                    ],
                    "Description": "Usage plan for stage v1",
                    "Quota": {
                        "Limit": 5000,
                        "Period": "MONTH"
                    },
                    "Throttle": {
                        "BurstLimit": 200,
                        "RateLimit": 100
                    },
                    "UsagePlanName": "Usage_plan_for_stage_v1"
                }
            },
            "RestApiUsagePlanKey": {
                "Type": "AWS::ApiGateway::UsagePlanKey",
                "Properties": {
                    "KeyId": {
                        "Ref": "MSGApiKey"
                    },
                    "KeyType": "API_KEY",
                    "UsagePlanId": {
                        "Ref": "MSGGatewayRestApiUsagePlan"
                    }
                }
            },
            "MSGApiKey": {
                "Type": "AWS::ApiGateway::ApiKey",
                "Properties": {
                    "Name": "MSGApiKey",
                    "Description": "CloudFormation API Key v1",
                    "Enabled": "true",
                    "StageKeys": [
                        {
                            "RestApiId": {
                                "Ref": "MSGGatewayRestApi"
                            },
                            "StageName": {
                                "Ref": "MSGGatewayRestApiStage"
                            }
                        }
                    ]
                }
            },
            "MSGGatewayRestApiStage": {
                "DependsOn": [
                    "ApiGatewayAccount"
                ],
                "Type": "AWS::ApiGateway::Stage",
                "Properties": {
                    "DeploymentId": {
                        "Ref": "RestAPIDeployment"
                    },
                    "MethodSettings": [
                        {
                            "DataTraceEnabled": true,
                            "HttpMethod": "*",
                            "LoggingLevel": "INFO",
                            "ResourcePath": "/*"
                        }
                    ],
                    "RestApiId": {
                        "Ref": "MSGGatewayRestApi"
                    },
                    "StageName": "v1"
                }
            },
            "ApiGatewayCloudWatchLogsRole": {
                "Type": "AWS::IAM::Role",
                "Properties": {
                    "AssumeRolePolicyDocument": {
                        "Version": "2012-10-17",
                        "Statement": [
                            {
                                "Effect": "Allow",
                                "Principal": {
                                    "Service": [
                                        "apigateway.amazonaws.com"
                                    ]
                                },
                                "Action": [
                                    "sts:AssumeRole"
                                ]
                            }
                        ]
                    },
                    "Policies": [
                        {
                            "PolicyName": "ApiGatewayLogsPolicy",
                            "PolicyDocument": {
                                "Version": "2012-10-17",
                                "Statement": [
                                    {
                                        "Effect": "Allow",
                                        "Action": [
                                            "logs:CreateLogGroup",
                                            "logs:CreateLogStream",
                                            "logs:DescribeLogGroups",
                                            "logs:DescribeLogStreams",
                                            "logs:PutLogEvents",
                                            "logs:GetLogEvents",
                                            "logs:FilterLogEvents"
                                        ],
                                        "Resource": "*"
                                    }
                                ]
                            }
                        }
                    ]
                }
            },
            "ApiGatewayAccount": {
                "Type": "AWS::ApiGateway::Account",
                "Properties": {
                    "CloudWatchRoleArn": {
                        "Fn::GetAtt": [
                            "ApiGatewayCloudWatchLogsRole",
                            "Arn"
                        ]
                    }
                }
            },
            "RestAPIDeployment": {
                "Type": "AWS::ApiGateway::Deployment",
                "DependsOn": [
                    "MSGGatewayRequest"
                ],
                "Properties": {
                    "RestApiId": {
                        "Ref": "MSGGatewayRestApi"
                    },
                    "StageName": "DummyStage"
                }
            },
            "ApiGatewayMSGResource": {
                "Type": "AWS::ApiGateway::Resource",
                "Properties": {
                    "RestApiId": {
                        "Ref": "MSGGatewayRestApi"
                    },
                    "ParentId": {
                        "Fn::GetAtt": [
                            "MSGGatewayRestApi",
                            "RootResourceId"
                        ]
                    },
                    "PathPart": "delivermessage"
                }
            },
            "MSGGatewayRequest": {
                "DependsOn": "LambdaPermission",
                "Type": "AWS::ApiGateway::Method",
                "Properties": {
                    "ApiKeyRequired": true,
                    "AuthorizationType": "NONE",
                    "HttpMethod": "POST",
                    "Integration": {
                        "Type": "AWS",
                        "IntegrationHttpMethod": "POST",
                        "Uri": {
                            "Fn::Join": [
                                "",
                                [
                                    "arn:aws:apigateway:",
                                    {
                                        "Ref": "AWS::Region"
                                    },
                                    ":lambda:path/2015-03-31/functions/",
                                    {
                                        "Fn::GetAtt": [
                                            "LambdaFunctionMessageSNSTopic",
                                            "Arn"
                                        ]
                                    },
                                    "/invocations"
                                ]
                            ]
                        },
                        "IntegrationResponses": [
                            {
                                "StatusCode": 200
                            },
                            {
                                "SelectionPattern": "500.*",
                                "StatusCode": 500
                            },
                            {
                                "SelectionPattern": "412.*",
                                "StatusCode": 412
                            }
                        ],
                        "RequestTemplates": {
                            "application/json": ""
                        }
                    },
                    "RequestParameters": {
                    },
                    "ResourceId": {
                        "Ref": "ApiGatewayMSGResource"
                    },
                    "RestApiId": {
                        "Ref": "MSGGatewayRestApi"
                    },
                    "MethodResponses": [
                        {
                            "StatusCode": 200
                        },
                        {
                            "StatusCode": 500
                        },
                        {
                            "StatusCode": 412
                        }
                    ]
                }
            },
            "LambdaPermission": {
                "Type": "AWS::Lambda::Permission",
                "Properties": {
                    "Action": "lambda:invokeFunction",
                    "FunctionName": {
                        "Fn::GetAtt": [
                            "LambdaFunctionMessageSNSTopic",
                            "Arn"
                        ]
                    },
                    "Principal": "apigateway.amazonaws.com",
                    "SourceArn": {
                        "Fn::Join": [
                            "",
                            [
                                "arn:aws:execute-api:",
                                {
                                    "Ref": "AWS::Region"
                                },
                                ":",
                                {
                                    "Ref": "AWS::AccountId"
                                },
                                ":",
                                {
                                    "Ref": "MSGGatewayRestApi"
                                },
                                "/*"
                            ]
                        ]
                    }
                }
            }
        }
    }
    

    #Received SMS in my phone executing a request to the API Gateway endpoint

    enter image description here

    Update - 2021

    The SNS js sdk provides a way of sending sms directly without the need of creating topics.