SHIFT-WIKI

--- Sjoerd Hooft's InFormation Technology ---

User Tools

Site Tools


samwebapp

SAM - Creating a WebApp in AWS Toolkit for VS Code

This this page is the follow up from SAM - Combining Lambda and Step Functions in AWS Toolkit for VS Code which was also a follow up from awstoolkitforvscode. We used the AWS Toolkit for VS Code to deploy a Step Functions State Machine and also created a Serverless Application to deploy Lambda functions. Then we created a new SAM to combine these two. Now that we've done that we are going to extend that by creating a frontend for it, we'll create a S3 bucket with a website and then an API Gateway to let the gateway talk to the Step Functions and Lambdas. This is no longer just 10 minute tutorial for Step Functions, we are extending it now. We will try to use the AWS Toolkit as much as we can.

Create a new SAM

To start fresh we'll copy the SAM-CallCenterApp folder to a new one and call it SAM-CallCenterWebApp. Because we need to change and setup quite a few resources and changes in the file, I'll first explain the added resources and changes and the show you the code.

Step Function State Machine

The CallCenterStateMachine we created works very well, but we don't any longer want to start it manually. We'll use a frontend to start it, which means we need an API Gateway to let the frontend (a website on S3) and the state machine talk to each other. This is very easy done by adding an Event property to the state machine. This will automatically create an API Gateway. This is the basic setup:

  CallCenterStateMachine:
    Type: AWS::Serverless::StateMachine # More info about State Machine Resource: https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-resource-statemachine.html
    Properties:
      DefinitionUri: statemachines/CallCenterStateMachine.asl.json
      Events:
        APIEvent:
          Type: Api
          Properties:
            Path: /case
            Method: post
            RestApiId: !Ref CallCenterAPI

Now notice the RestApiId property under the event. This is provided to add more configuration to the API Gateway then is possible by just using the Event property.

API Gateway

As explained before, an API Gateway will be automatically created if you us the event above. Actually, not only the API, but also stages, methods and the actual deployment. If a typical deployment is all you need, this will probably be enough. And using the RestApiId parameter you can even do little tweaks like naming the stage:

  CallCenterAPI:
    Type: AWS::Serverless::Api
    Properties: 
      StageName: prod

If you would deploy this however, you'd still have a problem. CORS. Since the S3 website, and the API Gateway are in different domains you need to enable CORS to have them talk to each other. Simple enough, in the API Gateway, select the resource, click enable CORS, enable all Options and Save:

✔ Add Access-Control-Allow-Headers, Access-Control-Allow-Methods, Access-Control-Allow-Origin Method Response Headers to OPTIONS method
✔ Add Access-Control-Allow-Headers, Access-Control-Allow-Methods, Access-Control-Allow-Origin Integration Response Header Mappings to OPTIONS method
✔ Add Access-Control-Allow-Origin Method Response Header to POST method
✔ Add Access-Control-Allow-Origin Integration Response Header Mapping to POST method
Your resource has been configured for CORS. If you see any errors in the resulting output above please check the error message and if necessary attempt to execute the failed step manually via the Method Editor.

And now you need to deploy the gateway afterwards. It's easy, but enabling CORS could save you quite some work. This is however a little bit more complicated. The first part is easy, we'll add CORS properties and default gateway responses to the API Gateway resource properties:

  CallCenterAPI:
    Type: AWS::Serverless::Api
    Properties: 
      StageName: prod
      Cors: 
        AllowMethods: "'POST, GET, OPTIONS, HEAD'"
        AllowHeaders: "'*'"
        AllowOrigin: "'*'"
      GatewayResponses:
        DEFAULT_4xx:
          ResponseParameters:
            Headers:
              Access-Control-Allow-Origin: "'*'"
              Access-Control-Allow-Methods: "'POST, OPTIONS'"
              Access-Control-Allow-Headers: "'*'"
        DEFAULT_5xx:
          ResponseParameters:
            Headers:
              Access-Control-Allow-Origin: "'*'"
              Access-Control-Allow-Methods: "'POST, OPTIONS'"
              Access-Control-Allow-Headers: "'*'"

Now this is sadly not enough. We'll also have to define the methods. For this we will use an existing and working API Gateway.

Export API Configuration

We will use Swagger to configure the rest of the API Gateway for CORS. With swagger we can define the CORS configuration inside of the DefinitionBody property of the API Gateway definition. It's most easy to pick an existing API gateway that is configured as you want it and then export the Swagger definition:

  • Inside the AWS API Gateway go to the API you want to export
  • Go to Stages and select the prod stage
  • Go to the export tab
  • In this case we already deploy an API with integration and some more basic settings setup, but we still need the “Export as Swagger + API Gateway Extensions” option and make sure to select the YAML option, as we do everything in YAML. A download starts, and the code is displayed on the page for easy copy/paste as well

Now we can change the output to fit our explicit needs and add the final piece to our deployment.

  CallCenterAPI:
    Type: AWS::Serverless::Api
    Properties: 
      StageName: prod
      Cors: 
        AllowMethods: "'POST, GET, OPTIONS, HEAD'"
        AllowHeaders: "'*'"
        AllowOrigin: "'*'"
      GatewayResponses:
        DEFAULT_4xx:
          ResponseParameters:
            Headers:
              Access-Control-Allow-Origin: "'*'"
              Access-Control-Allow-Methods: "'POST, OPTIONS'"
              Access-Control-Allow-Headers: "'*'"
        DEFAULT_5xx:
          ResponseParameters:
            Headers:
              Access-Control-Allow-Origin: "'*'"
              Access-Control-Allow-Methods: "'POST, OPTIONS'"
              Access-Control-Allow-Headers: "'*'"
      DefinitionBody:
        swagger: "2.0"
        info:
          title: "sam-callcentercors" #This is the name of the API
        paths:
          /case:
            post:
              consumes:
              - "application/json"
              responses:
                "200":
                  description: "200 response"
                  headers:
                    Access-Control-Allow-Origin:
                      type: "string"
                "400":
                  description: "400 response"
              x-amazon-apigateway-integration:
                credentials: !GetAtt CallCenterStateMachineAPIEventRole.Arn
                uri:
                  Fn::Sub: "arn:aws:apigateway:${AWS::Region}:states:action/StartExecution"
                responses:
                  "200":
                    statusCode: "200"
                    responseParameters:
                      method.response.header.Access-Control-Allow-Origin: "'*'"
                  "400":
                    statusCode: "400"
                requestTemplates:
                  application/json: 
                    Fn::Sub: "{\"input\": \"$util.escapeJavaScript($input.json('$'))\"\
                    , \"stateMachineArn\": \"${CallCenterStateMachine.Arn}\"\
                    }"
                passthroughBehavior: "when_no_match"
                httpMethod: "POST"
                type: "aws"
            options:
              consumes:
              - "application/json"
              produces:
              - "application/json"
              responses:
                "200":
                  description: "200 response"
                  headers:
                    Access-Control-Allow-Origin:
                      type: "string"
                    Access-Control-Allow-Methods:
                      type: "string"
                    Access-Control-Allow-Headers:
                      type: "string"
              x-amazon-apigateway-integration:
                responses:
                  default:
                    statusCode: "200"
                    responseParameters:
                      method.response.header.Access-Control-Allow-Methods: "'OPTIONS,POST'"
                      method.response.header.Access-Control-Allow-Headers: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'"
                      method.response.header.Access-Control-Allow-Origin: "'*'"
                    responseTemplates:
                      application/json: "{}\n"
                requestTemplates:
                  application/json: "{\n  \"statusCode\" : 200\n}\n"
                passthroughBehavior: "when_no_match"
                type: "mock"
Note that some parts are modified to fit my environment and preferences.

S3

Now that we have the API Gateway, the State Machine and the Lambda Functions, all we need is the S3 bucket to host a website on (and the actual website, hang on). The S3 bucket needs to be configured as a static website and it needs public access. Now S3 is a service that is nog part of the Serverless stack, but only of CloudFormation itself. Luckily, we can also add regular CloudFormation resources:

  CallCenterWebAppBucket:
    Type: AWS::S3::Bucket
    Properties:
      AccessControl: PublicRead
      WebsiteConfiguration:
        IndexDocument: index.html
        ErrorDocument: error.html
  
  CallCenterWebAppBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      PolicyDocument:
        Id: MyPolicy
        Version: 2012-10-17
        Statement:
          - Sid: PublicReadForGetBucketObjects
            Effect: Allow
            Principal: '*'
            Action: 's3:GetObject'
            Resource: !Join 
              - ''
              - - 'arn:aws:s3:::'
                - !Ref CallCenterWebAppBucket
                - /*
      Bucket: !Ref CallCenterWebAppBucket

Outputs

Now there are a few things that might come in hand when deploying a template like this. For example, in the frontend we'll need the API Gateway invoke url, so we know the address to talk too. Also, the url of the S3 bucket might be nice so we can go there directly once we've uploaded the website file and we can test. To use this we can use outputs:

  WebsiteURL:
    Description: URL for website hosted on S3
    Value: !GetAtt 
      - CallCenterWebAppBucket
      - WebsiteURL
  APIInvokeURL:
    Description: "API Prod stage endpoint"
    Value: !Sub "https://${CallCenterAPI}.execute-api.${AWS::Region}.amazonaws.com/prod/case"
  StateMachineIamRole:
    Description: "Implicit IAM Role created for State Machine"
    Value: !GetAtt CallCenterStateMachineRole.Arn
  StateMachineAPIEventIamRole:
    Description: "Implicit IAM Role created for State Machine API Event"
    Value: !GetAtt CallCenterStateMachineAPIEventRole.Arn
Notice that I also output the ARNs of some of the implicitly created IAM roles, which I needed to test with to fill in the correct credentials role in the API definition file.

End Template

So now that we have all the parts let's combine this together into one masterpiece:

AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: >
  SAM-CallCenterWebApp 
 
  Deploy State Machine, Lambda Functions, API Gateway and S3 bucket. 

Resources:
  CallCenterStateMachine:
    Type: AWS::Serverless::StateMachine 
    Properties:
      DefinitionUri: statemachines/CallCenterStateMachine.asl.json
      DefinitionSubstitutions:
        OpenCaseFunctionArn: !GetAtt OpenCaseFunction.Arn
        AssignCaseFunctionArn: !GetAtt AssignCaseFunction.Arn
        WorkOnCaseFunctionArn: !GetAtt WorkOnCaseFunction.Arn
        CloseCaseFunctionArn: !GetAtt CloseCaseFunction.Arn
        EscalateCaseFunctionArn: !GetAtt EscalateCaseFunction.Arn
      Policies: 
        - LambdaInvokePolicy:
            FunctionName: !Ref OpenCaseFunction
        - LambdaInvokePolicy:
            FunctionName: !Ref AssignCaseFunction
        - LambdaInvokePolicy:
            FunctionName: !Ref WorkOnCaseFunction
        - LambdaInvokePolicy:
            FunctionName: !Ref CloseCaseFunction
        - LambdaInvokePolicy:
            FunctionName: !Ref EscalateCaseFunction
      Events:
        APIEvent:
          Type: Api
          Properties:
            Path: /case
            Method: post
            RestApiId: !Ref CallCenterAPI
  
  CallCenterWebAppBucket:
    Type: AWS::S3::Bucket
    Properties:
      AccessControl: PublicRead
      WebsiteConfiguration:
        IndexDocument: index.html
        ErrorDocument: error.html
  
  CallCenterWebAppBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      PolicyDocument:
        Id: MyPolicy
        Version: 2012-10-17
        Statement:
          - Sid: PublicReadForGetBucketObjects
            Effect: Allow
            Principal: '*'
            Action: 's3:GetObject'
            Resource: !Join 
              - ''
              - - 'arn:aws:s3:::'
                - !Ref CallCenterWebAppBucket
                - /*
      Bucket: !Ref CallCenterWebAppBucket

  OpenCaseFunction:
    Type: AWS::Serverless::Function 
    Properties:
      CodeUri: functions/open-case/
      Handler: app.handler 
      Runtime: nodejs12.x
      Role: arn:aws:iam::952941930635:role/service-role/OpenCaseFunction-role-53xmbdiv

  AssignCaseFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: functions/assign-case/
      Handler: app.handler 
      Runtime: nodejs12.x
      Role: arn:aws:iam::952941930635:role/service-role/OpenCaseFunction-role-53xmbdiv

  WorkOnCaseFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: functions/work-on-case/
      Handler: app.handler 
      Runtime: nodejs12.x
      Role: arn:aws:iam::952941930635:role/service-role/OpenCaseFunction-role-53xmbdiv

  CloseCaseFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: functions/close-case/
      Handler: app.handler 
      Runtime: nodejs12.x
      Role: arn:aws:iam::952941930635:role/service-role/OpenCaseFunction-role-53xmbdiv

  EscalateCaseFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: functions/escalate-case/
      Handler: app.handler 
      Runtime: nodejs12.x
      Role: arn:aws:iam::952941930635:role/service-role/OpenCaseFunction-role-53xmbdiv
 
# API
  CallCenterAPI:
    Type: AWS::Serverless::Api
    Properties: 
      StageName: prod
      Cors: 
        AllowMethods: "'POST, GET, OPTIONS, HEAD'"
        AllowHeaders: "'*'"
        AllowOrigin: "'*'"
      GatewayResponses:
        DEFAULT_4xx:
          ResponseParameters:
            Headers:
              Access-Control-Allow-Origin: "'*'"
              Access-Control-Allow-Methods: "'POST, OPTIONS'"
              Access-Control-Allow-Headers: "'*'"
        DEFAULT_5xx:
          ResponseParameters:
            Headers:
              Access-Control-Allow-Origin: "'*'"
              Access-Control-Allow-Methods: "'POST, OPTIONS'"
              Access-Control-Allow-Headers: "'*'"
      DefinitionBody:
        swagger: "2.0"
        info:
          title: "sam-callcenterwebapp" #This is the name of the API
        paths:
          /case:
            post:
              consumes:
              - "application/json"
              responses:
                "200":
                  description: "200 response"
                  headers:
                    Access-Control-Allow-Origin:
                      type: "string"
                "400":
                  description: "400 response"
              x-amazon-apigateway-integration:
                credentials: !GetAtt CallCenterStateMachineAPIEventRole.Arn
                uri:
                  Fn::Sub: "arn:aws:apigateway:${AWS::Region}:states:action/StartExecution"
                responses:
                  "200":
                    statusCode: "200"
                    responseParameters:
                      method.response.header.Access-Control-Allow-Origin: "'*'"
                  "400":
                    statusCode: "400"
                requestTemplates:
                  application/json: 
                    Fn::Sub: "{\"input\": \"$util.escapeJavaScript($input.json('$'))\"\
                    , \"stateMachineArn\": \"${CallCenterStateMachine.Arn}\"\
                    }"
                passthroughBehavior: "when_no_match"
                httpMethod: "POST"
                type: "aws"
            options:
              consumes:
              - "application/json"
              produces:
              - "application/json"
              responses:
                "200":
                  description: "200 response"
                  headers:
                    Access-Control-Allow-Origin:
                      type: "string"
                    Access-Control-Allow-Methods:
                      type: "string"
                    Access-Control-Allow-Headers:
                      type: "string"
              x-amazon-apigateway-integration:
                responses:
                  default:
                    statusCode: "200"
                    responseParameters:
                      method.response.header.Access-Control-Allow-Methods: "'OPTIONS,POST'"
                      method.response.header.Access-Control-Allow-Headers: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'"
                      method.response.header.Access-Control-Allow-Origin: "'*'"
                    responseTemplates:
                      application/json: "{}\n"
                requestTemplates:
                  application/json: "{\n  \"statusCode\" : 200\n}\n"
                passthroughBehavior: "when_no_match"
                type: "mock"

Outputs:
  # In AWS Toolkit for VS Code the output is not displayed. Check the output in the AWS Cloudformation Console
  WebsiteURL:
    Description: URL for website hosted on S3
    Value: !GetAtt 
      - CallCenterWebAppBucket
      - WebsiteURL
  APIInvokeURL:
    Description: "API Prod stage endpoint"
    Value: !Sub "https://${CallCenterAPI}.execute-api.${AWS::Region}.amazonaws.com/prod/case"
  StateMachineIamRole:
    Description: "Implicit IAM Role created for State Machine"
    Value: !GetAtt CallCenterStateMachineRole.Arn
  StateMachineAPIEventIamRole:
    Description: "Implicit IAM Role created for State Machine API Event"
    Value: !GetAtt CallCenterStateMachineAPIEventRole.Arn

Front End Website

Now we need the website, I created a small one pager with all CSS and javascript on the same page. Notice that it needs the API Invoke URL inside the javascript, so once've deployed check the output section in the AWS CloudFormation console:

<!DOCTYPE html>
<html lang="nl">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Call Center Web App</title>
 
    <!-- CSS Stylesheets -->
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/css/bootstrap.min.css" integrity="sha384-9aIt2nRpC12Uk9gS9baDl411NQApFmC26EwAOH8WgZl5MYYxFfc+NcPb1dKGj7Sk" crossorigin="anonymous">
 
    <!-- Bootstrap Scripts -->
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/js/bootstrap.min.js" integrity="sha384-OgVRvuATP1z7JjHLkuOU7Xw704+h835Lr+6QL9UvYjZE3Ipu6Tp75j7Bh/kR0JKI" crossorigin="anonymous"></script>
 
    <!-- Internal CSS -->
    <style>
        .container-fluid {
            padding: 7% 15%;
        }
    </style>
</head>
<body>
 
    <section id="cta">
 
      <div class="container-fluid">
        <p>Please enter your ticket number </p>
 
        <form class="needs-validation" novalidate id="contactform">
 
            <div class="form-row needs-validation">
              <div class="input-group mb-3 col-md-6">
                  <div class="input-group-prepend">
                    <span class="input-group-text">Ticket number</span>
                  </div>
                  <input type="number" class="form-control" id="ticketNumber"  maxLength="5" placeholder="A ticket nr" required>
                  <div class="invalid-feedback">Your numeric tiket number </div>
              </div>
            </div>
 
          <button class="btn btn-primary my-3" type="submit">Submit</button>
 
        </form>
 
        <p class="mt-2"><small>To check the API callback, press F12 and check the console log. </small></p>
 
      </div>
 
    </section>
 
 
 
    <section id="footer">
 
        <div class="container-fluid">
          <div class="row">
            <div class="col-md-6">
                <p>Copyright © <script type="text/javascript">document.write(new Date().getFullYear());</script>.</p>
            </div>
 
            <div class="col-md-6">
                <p>Designed and created by <a href="https://ctrlaltshift.nl" target="_blank">ctrlaltshift.nl</a>.</p>
            </div>
          </div>
 
        </div>
 
 
      </section>
 
    <!-- Bootstrap Script  -->
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js"></script>
    <!-- Form Validation and Sending -->
    <script>
        (function() {
            'use strict';
            //console.log("Adding event listeners once the DOM has loaded for form validation and handling");
            window.addEventListener('load', function() {
            // Fetch all the forms we want to apply custom Bootstrap validation styles to
            var forms = document.getElementsByClassName('needs-validation');
            // Loop over them and prevent submission
            var validation = Array.prototype.filter.call(forms, function(form) {
                form.addEventListener('submit', function(event) {
                if (form.checkValidity() === false) {
                    event.preventDefault();
                    event.stopPropagation();
                }
                form.classList.add('was-validated');
                if (form.checkValidity() === true) {
                    console.log ("Start collecting form data and sending to backend for processing. ");
                    collectInputData(event);
                    event.preventDefault();
                }
                }, false);
            });
            }, false);
        })();
 
        function collectInputData(event) {
            // Input
            var FormTicketNumber = $("#ticketNumber").val();
            // Collect all data into one array
            var inputData = {
                formTicketNumber: FormTicketNumber,
            };
            console.log("InputData is collected, start submit to the backend API");
            submitToAPI(inputData);
        }
 
        // Used for contact
        function submitToAPI(inputData) {
            $.ajax({
                method: 'POST',
                url: 'https://xxxxxxxxxxxx.execute-api.eu-west-1.amazonaws.com/prod/case',
                dataType: "JSON",
                crossDomain: "true",
                data: JSON.stringify(inputData),
                contentType: 'application/json',
                success: completeTicketRequest,
                error: function ajaxError(jqXHR, textStatus, errorThrown) {
                    console.error('Error requesting call center ticket form: ', textStatus, ', Details: ', errorThrown);
                    console.error('Response: ', jqXHR.responseText);
                    alert('An error occured:\n' + jqXHR.responseText);
                }
            });
        }
 
        function completeTicketRequest(result) {
            console.log('Response received from API: ', result);  
        }
 
    </script>
 
    </body>
    </html>
Don't forget to update the url value inside of the submitToAPI function.

Deploy

Just as a summary, follow these steps to make this working:

Open the command palette and search for AWS and select AWS: Deploy Serverless Application

  • Select the just created template.yaml from the list
  • Select the region to deploy to: Europe (Ireland) - eu-west-1
  • Provide the name of the S3 bucket we created earlier: vscode-awstoolkitsam
  • Provide the name of the (CloudFormation) stack. Notice that all lambda resources will be created with this name as a prefix (not the step function), so keep the name short and simple: sam-callcenterwebapp
  • Now go in the AWS Console and go to Cloudformation. Click on the CloudFormation stack you just deployed and copy the APIInvokeURL value and update the url value in the javascript function as explained above
  • Now go to the AWS S3 console and click on the S3 bucket you just created. Upload the html file we've created before and make sure to name it index.html
  • Now go back to the AWS Cloudformation console and again in the output section you can click on the link for WebsiteURL. This will take you directly to the website so you can test your webapp.

Additional Configuration

IAM Roles

Now, in the file above quite a few IAM roles are created or used. For Lambda I used one of the roles that was created in a previous attempt, and for the State Machine and the API Event of the State Machine new roles are created. Now throw in that in the next section I also want logging I will end up with somewhere about 8 roles (assuming that Lambda also does not uses a predefined role). I decided I wanted a bit more control.

Lambda

In the Lambda documentation is explained that to run a Lambda function you need an execution role, with a minimum of access to Amazon CloudWatch Logs for log streaming.

We can define a new role using the default AWS managed IAM policy:

  CallCenterBasicLambdaRole:
    Type: AWS::IAM::Role
    Properties:
      Description: "Basic Lamda execution role"
      RoleName: !Join
        - ''
        - - !Ref AWS::StackName 
          - '-'
          - !Ref AWS::Region
          - '-'
          - CallCenterBasicLambdaRole
      ManagedPolicyArns:
        - 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          -
            Effect: Allow
            Principal:
              Service:
                - 'lambda.amazonaws.com'
            Action:
              - 'sts:AssumeRole'

And change the Lambda functions to use it:

  OpenCaseFunction:
    Type: AWS::Serverless::Function 
    Properties:
      CodeUri: functions/open-case/
      Handler: app.handler 
      Runtime: nodejs12.x
      Role: !GetAtt CallCenterBasicLambdaRole.Arn
Note that naming the IAM roles is generally advised against, but if you do you should include the region like I did.

State Machine

For the state machine I checked the 10 minute tutorial for Step Functions to verify the permissions needed:

  CallCenterBasicStepFunctionsRole:
    Type: AWS::IAM::Role
    Properties:
      Description: "Basic Step Functions role. Allows to invoke Lambda Functions. "
      RoleName: !Join
        - ''
        - - !Ref AWS::StackName 
          - '-'
          - !Ref AWS::Region
          - '-'
          - CallCenterBasicStepFunctionsRole
      ManagedPolicyArns:
        - 'arn:aws:iam::aws:policy/service-role/AWSLambdaRole'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          -
            Effect: Allow
            Principal:
              Service:
                - 'states.amazonaws.com'
            Action:
              - 'sts:AssumeRole'

And this also needs to be updated in the state machine:

  CallCenterStateMachine:
    Type: AWS::Serverless::StateMachine 
    Properties:
      DefinitionUri: statemachines/CallCenterStateMachine.asl.json
      DefinitionSubstitutions:
        OpenCaseFunctionArn: !GetAtt OpenCaseFunction.Arn
        AssignCaseFunctionArn: !GetAtt AssignCaseFunction.Arn
        WorkOnCaseFunctionArn: !GetAtt WorkOnCaseFunction.Arn
        CloseCaseFunctionArn: !GetAtt CloseCaseFunction.Arn
        EscalateCaseFunctionArn: !GetAtt EscalateCaseFunction.Arn
      Role: !GetAtt CallCenterBasicStepFunctionsRole.Arn
      # Policies: 
      #   - LambdaInvokePolicy:
      #       FunctionName: !Ref OpenCaseFunction
      #   - LambdaInvokePolicy:
      #       FunctionName: !Ref AssignCaseFunction
      #   - LambdaInvokePolicy:
      #       FunctionName: !Ref WorkOnCaseFunction
      #   - LambdaInvokePolicy:
      #       FunctionName: !Ref CloseCaseFunction
      #   - LambdaInvokePolicy:
      #       FunctionName: !Ref EscalateCaseFunction
      Events:
        APIEvent:
          Type: Api
          Properties:
            Path: /case
            Method: post
            RestApiId: !Ref CallCenterAPI
Notice that you can leave out all the policies and simply put in the role property.

State Machine API Event

This is the last role and, apparently, the trickiest. This role is used in the API gateway to trigger the Step Function State Machine. There is no AWS managed role for it, so we need to create a role with a custom policy. On top of that there is also a bug that the default role is still being created, even though it's not required any more, and can be deleted without impact. More information on that at the end of this section.

This is the yaml code for the role:

  CallCenterAPIGatewayRole:
    Type: AWS::IAM::Role
    Properties:
      Description: "API Gateway role. Allows to invoke Step Functions state machine. "
      RoleName: !Join
        - ''
        - - !Ref AWS::StackName 
          - '-'
          - !Ref AWS::Region
          - '-'
          - CallCenterAPIGatewayRole
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          -
            Effect: Allow
            Principal:
              Service:
                - 'apigateway.amazonaws.com'
            Action:
              - 'sts:AssumeRole'
      Policies:
        -
          PolicyName: 'StateMachine-StartExecution'
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              -
                Effect: Allow
                Action:
                  - 'states:StartExecution'
                Resource: !GetAtt CallCenterStateMachine.Arn

And then we need to update the API Gateway configuration. Now that is a big part, so I'll only show the section involved:

x-amazon-apigateway-integration:
                #credentials: !GetAtt CallCenterStateMachineAPIEventRole.Arn
                credentials: !GetAtt CallCenterAPIGatewayRole.Arn

So, now we have a custom Role for the API Gateway to trigger the state machine, which means we can delete the automatically created API Event Role. As said before, this one is still being created but can be deleted. It's called with format like <samstackname>-<statemachinename>APIEventRole-<randomstringof12chars>. This one will have the exact same policy as the role we created above, though named different, and can be deleted.

Posted this on Stack Overflow as well.

CloudWatch

Now, I like logging, in case stuff goes wrong, and I don't like it if I need to enable it afterwards I have something going wrong. So, let's enable cloudwatch for everything.

Lambda

For Lambda we have nothing to do, logging is enabled by default .

State Machine

The state machine logging is a bit complex. Not only needs it a predefined log group, it also needs extra permissions.

Add the following permissions to the CallCenterBasicStepFunctionsRole to allow the state machine to log to Cloudwatch:

- 'arn:aws:iam::aws:policy/CloudWatchLogsFullAccess'

Add the following resource to the template to create a loggroup with as an extra to delete all logs after 30 days:

  CallCenterStateMachineLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub /aws/stepfunctions/CallCenterStateMachine
      RetentionInDays: 30 #[1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1827, 3653]  

And the following to the properties of the state machine resource:

      Logging:
        Destinations:
          - CloudWatchLogsLogGroup: 
              LogGroupArn: !GetAtt CallCenterStateMachineLogGroup.Arn
        IncludeExecutionData: true
        Level: ALL
Note: see Step Functions Log Levels for log level options and implications.

API GateWay

API logging is done on the stage level, you can add this part to the API Gateway properties:

      MethodSettings:
        - LoggingLevel: INFO # OFF, ERROR, INFO
          ResourcePath: '/*' # allows for logging on any resource
          HttpMethod: '*' # allows for logging on any method

API Gateway Account

Now the previous setting defines the logging level but it actually doesn't really enable logging. For logging, the API Gateway needs access to CloudWatch, and this is definedas a global setting for all API Gateways through the API Gateway Account.

So we need an extra IAM role with permissions to log to CloudWatch, which is actually a managed AWS policy:

  CallCenterAPIGatewayAccountRole:
    Type: AWS::IAM::Role
    Properties:
      Description: "API Gateway role, provides access to Cloudwatch for logging. "
      RoleName: !Join
        - ''
        - - !Ref AWS::StackName 
          - '-'
          - !Ref AWS::Region
          - '-'
          - CallCenterAPIGatewayAccountRole
      ManagedPolicyArns:
        - 'arn:aws:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          -
            Effect: Allow
            Principal:
              Service:
                - 'apigateway.amazonaws.com'
            Action:
              - 'sts:AssumeRole'

And we need to define an extra resource of APIGateway Account type:

  CallCenterAPIGatewayAccount:
    Type: AWS::ApiGateway::Account 
    Properties: 
      CloudWatchRoleArn: !GetAtt CallCenterAPIGatewayAccountRole.Arn
Note that if you already have this configured, this will be overwritten, so you only need to set this up once.

S3 BucketName Mapping and Retain

Now all that's left is to configure the S3 bucket a bit more, I want to set a name, but because s3 buckets need to be unique (worldwide) I have to setup a name per account. I also, just for sanity want to keep the S3 bucket in case I delete the CloudFormation stack.

So that gives me this S3 policy:

  CallCenterWebAppBucket:
    Type: AWS::S3::Bucket
    DeletionPolicy: Retain
    Properties:
      BucketName: !FindInMap [AccountMap, !Ref "AWS::AccountId", s3bucketname]
      AccessControl: PublicRead
      WebsiteConfiguration:
        IndexDocument: index.html
        ErrorDocument: error.html

And this as the mapping:

Mappings: 
  AccountMap: #Different settings based on the AWS Account to which is being deployed
    "xxxxxxxxxxxxx":
      "s3bucketname": "callcentertestbucket"
    "xxxxxxxxxxxxx":
      "s3bucketname": "callcenterprodbucket"
Notice that upon deletion of the stack the s3 bucket is now being retained, resulting in the error “<bucketname> already exists”. To prevent this, either remove the S3 section from the template.yaml or remove the s3 bucket manually.

End Result

The total template.yaml:

| template.yaml
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: >
  SAM-CallCenterWebApp
 
  Deploy State Machine, Lambda Functions, API Gateway and S3 bucket. 

Mappings: 
  AccountMap: #Different settings based on the AWS Account to which is being deployed
    "xxxxxxxxxxxxx":
      "s3bucketname": "callcentertestbucket"
    "xxxxxxxxxxxxx":
      "s3bucketname": "callcenterprodbucket"

Resources:
  CallCenterStateMachine:
    Type: AWS::Serverless::StateMachine 
    Properties:
      DefinitionUri: statemachines/CallCenterStateMachine.asl.json
      DefinitionSubstitutions:
        OpenCaseFunctionArn: !GetAtt OpenCaseFunction.Arn
        AssignCaseFunctionArn: !GetAtt AssignCaseFunction.Arn
        WorkOnCaseFunctionArn: !GetAtt WorkOnCaseFunction.Arn
        CloseCaseFunctionArn: !GetAtt CloseCaseFunction.Arn
        EscalateCaseFunctionArn: !GetAtt EscalateCaseFunction.Arn
      Role: !GetAtt CallCenterBasicStepFunctionsRole.Arn
      Logging:
        Destinations:
          - CloudWatchLogsLogGroup: 
              LogGroupArn: !GetAtt CallCenterStateMachineLogGroup.Arn
        IncludeExecutionData: true
        Level: ALL
      Events:
        APIEvent:
          Type: Api
          Properties:
            Path: /case
            Method: post
            RestApiId: !Ref CallCenterAPI
  
  CallCenterWebAppBucket:
    Type: AWS::S3::Bucket
    DeletionPolicy: Retain
    Properties:
      BucketName: !FindInMap [AccountMap, !Ref "AWS::AccountId", s3bucketname]
      AccessControl: PublicRead
      WebsiteConfiguration:
        IndexDocument: index.html
        ErrorDocument: error.html
  
  CallCenterWebAppBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      PolicyDocument:
        Id: MyPolicy
        Version: 2012-10-17
        Statement:
          - Sid: PublicReadForGetBucketObjects
            Effect: Allow
            Principal: '*'
            Action: 's3:GetObject'
            Resource: !Join 
              - ''
              - - 'arn:aws:s3:::'
                - !Ref CallCenterWebAppBucket
                - /*
      Bucket: !Ref CallCenterWebAppBucket

  OpenCaseFunction:
    Type: AWS::Serverless::Function 
    Properties:
      CodeUri: functions/open-case/
      Handler: app.handler 
      Runtime: nodejs12.x
      Role: !GetAtt CallCenterBasicLambdaRole.Arn

  AssignCaseFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: functions/assign-case/
      Handler: app.handler 
      Runtime: nodejs12.x
      Role: !GetAtt CallCenterBasicLambdaRole.Arn

  WorkOnCaseFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: functions/work-on-case/
      Handler: app.handler 
      Runtime: nodejs12.x
      Role: !GetAtt CallCenterBasicLambdaRole.Arn

  CloseCaseFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: functions/close-case/
      Handler: app.handler 
      Runtime: nodejs12.x
      Role: !GetAtt CallCenterBasicLambdaRole.Arn

  EscalateCaseFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: functions/escalate-case/
      Handler: app.handler 
      Runtime: nodejs12.x
      Role: !GetAtt CallCenterBasicLambdaRole.Arn
 
# API
  CallCenterAPI:
    Type: AWS::Serverless::Api
    Properties: 
      StageName: prod
      Cors: 
        AllowMethods: "'POST, GET, OPTIONS, HEAD'"
        AllowHeaders: "'*'"
        AllowOrigin: "'*'"
      MethodSettings:
        - LoggingLevel: INFO # OFF, ERROR, INFO
          ResourcePath: '/*' # allows for logging on any resource
          HttpMethod: '*' # allows for logging on any method
      GatewayResponses:
        DEFAULT_4xx:
          ResponseParameters:
            Headers:
              Access-Control-Allow-Origin: "'*'"
              Access-Control-Allow-Methods: "'POST, OPTIONS'"
              Access-Control-Allow-Headers: "'*'"
        DEFAULT_5xx:
          ResponseParameters:
            Headers:
              Access-Control-Allow-Origin: "'*'"
              Access-Control-Allow-Methods: "'POST, OPTIONS'"
              Access-Control-Allow-Headers: "'*'"
      DefinitionBody:
        swagger: "2.0"
        info:
          title: "sam-callcenterwebapp" #This is the name of the API
        paths:
          /case:
            post:
              consumes:
              - "application/json"
              responses:
                "200":
                  description: "200 response"
                  headers:
                    Access-Control-Allow-Origin:
                      type: "string"
                "400":
                  description: "400 response"
              x-amazon-apigateway-integration:
                credentials: !GetAtt CallCenterAPIGatewayRole.Arn
                uri:
                  Fn::Sub: "arn:aws:apigateway:${AWS::Region}:states:action/StartExecution"
                responses:
                  "200":
                    statusCode: "200"
                    responseParameters:
                      method.response.header.Access-Control-Allow-Origin: "'*'"
                  "400":
                    statusCode: "400"
                requestTemplates:
                  application/json: 
                    Fn::Sub: "{\"input\": \"$util.escapeJavaScript($input.json('$'))\"\
                    , \"stateMachineArn\": \"${CallCenterStateMachine.Arn}\"\
                    }"
                passthroughBehavior: "when_no_match"
                httpMethod: "POST"
                type: "aws"
            options:
              consumes:
              - "application/json"
              produces:
              - "application/json"
              responses:
                "200":
                  description: "200 response"
                  headers:
                    Access-Control-Allow-Origin:
                      type: "string"
                    Access-Control-Allow-Methods:
                      type: "string"
                    Access-Control-Allow-Headers:
                      type: "string"
              x-amazon-apigateway-integration:
                responses:
                  default:
                    statusCode: "200"
                    responseParameters:
                      method.response.header.Access-Control-Allow-Methods: "'OPTIONS,POST'"
                      method.response.header.Access-Control-Allow-Headers: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'"
                      method.response.header.Access-Control-Allow-Origin: "'*'"
                    responseTemplates:
                      application/json: "{}\n"
                requestTemplates:
                  application/json: "{\n  \"statusCode\" : 200\n}\n"
                passthroughBehavior: "when_no_match"
                type: "mock"

  CallCenterAPIGatewayAccount:
    Type: AWS::ApiGateway::Account 
    Properties: 
      CloudWatchRoleArn: !GetAtt CallCenterAPIGatewayAccountRole.Arn
 
#IAM                
  CallCenterBasicLambdaRole:
    Type: AWS::IAM::Role
    Properties:
      Description: "Basic Lamda execution role, provides access to Cloudwatch for logging. "
      RoleName: !Join
        - ''
        - - !Ref AWS::StackName 
          - '-'
          - !Ref AWS::Region
          - '-'
          - CallCenterBasicLambdaRole
      ManagedPolicyArns:
        - 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          -
            Effect: Allow
            Principal:
              Service:
                - 'lambda.amazonaws.com'
            Action:
              - 'sts:AssumeRole'

  CallCenterBasicStepFunctionsRole:
    Type: AWS::IAM::Role
    Properties:
      Description: "Basic Step Functions role. Allows to invoke Lambda Functions, and logging to Cloudwatch. "
      RoleName: !Join
        - ''
        - - !Ref AWS::StackName 
          - '-'
          - !Ref AWS::Region
          - '-'
          - CallCenterBasicStepFunctionsRole
      ManagedPolicyArns:
        - 'arn:aws:iam::aws:policy/service-role/AWSLambdaRole'
        - 'arn:aws:iam::aws:policy/CloudWatchLogsFullAccess'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          -
            Effect: Allow
            Principal:
              Service:
                - 'states.amazonaws.com'
            Action:
              - 'sts:AssumeRole'

  CallCenterAPIGatewayRole:
    Type: AWS::IAM::Role
    Properties:
      Description: "API Gateway role. Allows to invoke Step Functions state machine. "
      RoleName: !Join
        - ''
        - - !Ref AWS::StackName 
          - '-'
          - !Ref AWS::Region
          - '-'
          - CallCenterAPIGatewayRole
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          -
            Effect: Allow
            Principal:
              Service:
                - 'apigateway.amazonaws.com'
            Action:
              - 'sts:AssumeRole'
      Policies:
        -
          PolicyName: 'StateMachine-StartExecution'
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              -
                Effect: Allow
                Action:
                  - 'states:StartExecution'
                Resource: !GetAtt CallCenterStateMachine.Arn
  
  CallCenterAPIGatewayAccountRole:
    Type: AWS::IAM::Role
    Properties:
      Description: "API Gateway role, provides access to Cloudwatch for logging. "
      RoleName: !Join
        - ''
        - - !Ref AWS::StackName 
          - '-'
          - !Ref AWS::Region
          - '-'
          - CallCenterAPIGatewayAccountRole
      ManagedPolicyArns:
        - 'arn:aws:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          -
            Effect: Allow
            Principal:
              Service:
                - 'apigateway.amazonaws.com'
            Action:
              - 'sts:AssumeRole'

  CallCenterStateMachineLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub /aws/stepfunctions/CallCenterStateMachine
      RetentionInDays: 30 #[1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1827, 3653]             

Outputs:
  # In AWS Toolkit for VS Code the output is not displayed. Check the output in the AWS Cloudformation Console
  WebsiteURL:
    Description: URL for website hosted on S3
    Value: !GetAtt 
      - CallCenterWebAppBucket
      - WebsiteURL
  APIInvokeURL:
    Description: "API Prod stage endpoint"
    Value: !Sub "https://${CallCenterAPI}.execute-api.${AWS::Region}.amazonaws.com/prod/case"

Alternative to Swagger

As explained above, the API gateway gets automatically created but it was a bit hard to find the correct way to deploy the CORS part as well. Before I figured out the swagger optionI used above I explored the option below, to create all the API Gateway resources separately. I found it more clear on what it does exactly, but it wouldn't work in combination with the automatically created API Gateway (the CloudFormation stack will try to create two methods for case), which means you have to create all the methods from scratch. As I said, I got pretty far and all options below work correctly, just not combined with the SAM template above but I found it a shame to just delete it.

  # CaseResource:
  #   Type: AWS::ApiGateway::Resource
  #   Properties: 
  #     ParentId: !GetAtt CallCenterAPI.RootResourceId
  #     PathPart: case
  #     RestApiId: !Ref CallCenterAPI
 
  # OptionsMethod: 
  #   Type: AWS::ApiGateway::Method
  #   Properties:
  #     RestApiId: !Ref CallCenterAPI
  #     ResourceId: !Ref CaseResource
  #     HttpMethod: OPTIONS
  #     AuthorizationType: NONE
  #     Integration:
  #       IntegrationResponses:
  #       - StatusCode: 200
  #         ResponseParameters:
  #           method.response.header.Access-Control-Allow-Headers: "'*'"
  #           method.response.header.Access-Control-Allow-Methods: "'POST,OPTIONS'"
  #           method.response.header.Access-Control-Allow-Origin: "'*'"
  #     MethodResponses:
  #         - StatusCode: 200
  #           ResponseParameters:
  #             method.response.header.Access-Control-Allow-Headers: true
  #             method.response.header.Access-Control-Allow-Methods: true
  #             method.response.header.Access-Control-Allow-Origin: true
 
  # APIDeployment:
  #   DependsOn: OptionsMethod
  #   Type: AWS::ApiGateway::Deployment
  #   Properties:
  #     RestApiId: !Ref CallCenterAPI
  #     StageName: prod
 
 
  # PostMethod: 
  #   Type: AWS::ApiGateway::Method
  #   Properties:
  #     RestApiId: !Ref CallCenterAPI
  #     ResourceId: !Ref CaseResource
  #     HttpMethod: POST
  #     AuthorizationType: NONE
  #     Integration:
  #       IntegrationResponses:
  #       - StatusCode: 200
  #         ResponseParameters:
  #           method.response.header.Access-Control-Allow-Origin: "'*'"
  #     MethodResponses:
  #         - StatusCode: 200
  #           ResponseParameters:
  #             method.response.header.Access-Control-Allow-Origin: true
 
  # APIDeployment:
  #   DependsOn: 
  #     - PostMethod
  #     - OptionsMethod
  #   Type: AWS::ApiGateway::Deployment
  #   Properties:
  #     RestApiId: !Ref CallCenterAPI
  #     StageName: prod

Resources

samwebapp.txt · Last modified: 2021/09/24 00:25 by 127.0.0.1