Table of Contents
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
- CORS.