diff --git a/integration/resources/expected/single/state_machine_with_api.json b/integration/resources/expected/single/state_machine_with_api.json new file mode 100644 index 000000000..98fd6fb8b --- /dev/null +++ b/integration/resources/expected/single/state_machine_with_api.json @@ -0,0 +1,10 @@ +[ + { "LogicalResourceId": "HelloWorldFunction", "ResourceType": "AWS::Lambda::Function" }, + { "LogicalResourceId": "HelloWorldFunctionRole", "ResourceType": "AWS::IAM::Role" }, + { "LogicalResourceId": "MyApi", "ResourceType": "AWS::ApiGateway::RestApi" }, + { "LogicalResourceId": "MyApiDeployment", "ResourceType": "AWS::ApiGateway::Deployment" }, + { "LogicalResourceId": "MyApiProdStage", "ResourceType": "AWS::ApiGateway::Stage" }, + { "LogicalResourceId": "Post", "ResourceType": "AWS::StepFunctions::StateMachine" }, + { "LogicalResourceId": "PostPostEchoRole", "ResourceType": "AWS::IAM::Role" }, + { "LogicalResourceId": "PostRole", "ResourceType": "AWS::IAM::Role" } +] diff --git a/integration/resources/templates/single/state_machine_with_api.yaml b/integration/resources/templates/single/state_machine_with_api.yaml new file mode 100644 index 000000000..3d09b74cb --- /dev/null +++ b/integration/resources/templates/single/state_machine_with_api.yaml @@ -0,0 +1,39 @@ +Transform: AWS::Serverless-2016-10-31 +Resources: + MyApi: + Type: AWS::Serverless::Api + Properties: + StageName: Prod + HelloWorldFunction: + Type: AWS::Serverless::Function + Properties: + InlineCode: | + def handler(event, context): + print(event) + return "do nothing" + Handler: index.handler + Runtime: python3.8 + Post: + Type: AWS::Serverless::StateMachine + Properties: + Policies: + - arn:aws:iam::aws:policy/AWSLambda_FullAccess + Definition: + StartAt: One + States: + One: + Type: Task + Resource: !GetAtt HelloWorldFunction.Arn + End: true + Events: + PostEcho: + Type: Api + Properties: + RestApiId: !Ref MyApi + Path: /echo + Method: POST + UnescapeMappingTemplate: true + +Outputs: + ApiEndpoint: + Value: !Sub "https://${MyApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/echo" diff --git a/integration/single/test_basic_api.py b/integration/single/test_basic_api.py index 72b2f1394..479e61129 100644 --- a/integration/single/test_basic_api.py +++ b/integration/single/test_basic_api.py @@ -1,6 +1,8 @@ +import json import logging from unittest.case import skipIf +import requests from tenacity import stop_after_attempt, wait_exponential, retry_if_exception_type, after_log, wait_random from integration.helpers.base_test import BaseTest @@ -113,3 +115,22 @@ def test_basic_api_with_tags(self): self.assertIsNotNone(stage) self.assertEqual(stage["tags"]["TagKey1"], "TagValue1") self.assertEqual(stage["tags"]["TagKey2"], "") + + def test_state_machine_with_api_single_quotes_input(self): + """ + Pass single quotes in input JSON to a StateMachine + See https://github.com/aws/serverless-application-model/issues/1895 + """ + self.create_and_verify_stack("single/state_machine_with_api") + + stack_output = self.get_stack_outputs() + api_endpoint = stack_output.get("ApiEndpoint") + + input_json = {"f'oo": {"hello": "'wor'l'd'''"}} + response = requests.post(api_endpoint, json=input_json) + self.assertEqual(response.status_code, 200) + + execution_arn = response.json()["executionArn"] + execution = self.client_provider.sfn_client.describe_execution(executionArn=execution_arn) + execution_input = json.loads(execution["input"]) + self.assertEqual(execution_input, input_json) diff --git a/samtranslator/model/stepfunctions/events.py b/samtranslator/model/stepfunctions/events.py index 73c1734a0..5db076cda 100644 --- a/samtranslator/model/stepfunctions/events.py +++ b/samtranslator/model/stepfunctions/events.py @@ -252,6 +252,7 @@ class Api(EventSource): "RestApiId": PropertyType(True, is_str()), "Stage": PropertyType(False, is_str()), "Auth": PropertyType(False, is_type(dict)), + "UnescapeMappingTemplate": PropertyType(False, is_type(bool)), } def resources_to_link(self, resources): @@ -356,12 +357,18 @@ def _add_swagger_integration(self, api, resource, role, intrinsics_resolver): if CONDITION in resource.resource_attributes: condition = resource.resource_attributes[CONDITION] + request_template = ( + self._generate_request_template_unescaped(resource) + if self.UnescapeMappingTemplate + else self._generate_request_template(resource) + ) + editor.add_state_machine_integration( self.Path, self.Method, integration_uri, role.get_runtime_attr("arn"), - self._generate_request_template(resource), + request_template, condition=condition, ) @@ -453,3 +460,27 @@ def _generate_request_template(self, resource): ) } return request_templates + + def _generate_request_template_unescaped(self, resource): + """Generates the Body mapping request template for the Api. This allows for the input + request to the Api to be passed as the execution input to the associated state machine resource. + + Unescapes single quotes such that it's valid JSON. + + :param model.stepfunctions.resources.StepFunctionsStateMachine resource; the state machine + resource to which the Api event source must be associated + + :returns: a body mapping request which passes the Api input to the state machine execution + :rtype: dict + """ + request_templates = { + "application/json": fnSub( + # Need to unescape single quotes escaped by escapeJavaScript. + # Also the mapping template isn't valid JSON, so can't use json.dumps(). + # See https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html#util-template-reference + """{"input": "$util.escapeJavaScript($input.json('$')).replaceAll("\\\\'","'")", "stateMachineArn": "${""" + + resource.logical_id + + """}"}""" + ) + } + return request_templates diff --git a/tests/translator/input/state_machine_with_api.yaml b/tests/translator/input/state_machine_with_api.yaml new file mode 100644 index 000000000..5b6dbae29 --- /dev/null +++ b/tests/translator/input/state_machine_with_api.yaml @@ -0,0 +1,35 @@ +Transform: AWS::Serverless-2016-10-31 +Resources: + MyApi: + Type: AWS::Serverless::Api + Properties: + StageName: Prod + HelloWorldFunction: + Type: AWS::Serverless::Function + Properties: + InlineCode: | + def handler(event, context): + print(event) + return "do nothing" + Handler: index.handler + Runtime: python3.8 + Post: + Type: AWS::Serverless::StateMachine + Properties: + Policies: + - arn:aws:iam::aws:policy/AWSLambda_FullAccess + Definition: + StartAt: One + States: + One: + Type: Task + Resource: !GetAtt HelloWorldFunction.Arn + End: true + Events: + PostEcho: + Type: Api + Properties: + RestApiId: !Ref MyApi + Path: /echo + Method: POST + UnescapeMappingTemplate: true diff --git a/tests/translator/output/aws-cn/state_machine_with_api.json b/tests/translator/output/aws-cn/state_machine_with_api.json new file mode 100644 index 000000000..9f164e9cf --- /dev/null +++ b/tests/translator/output/aws-cn/state_machine_with_api.json @@ -0,0 +1,249 @@ +{ + "Resources": { + "HelloWorldFunctionRole": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "lambda.amazonaws.com" + ] + } + } + ] + }, + "ManagedPolicyArns": [ + "arn:aws-cn:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ], + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + } + }, + "MyApiDeployment5866b9014d": { + "Type": "AWS::ApiGateway::Deployment", + "Properties": { + "Description": "RestApi deployment id: 5866b9014d5a1c815da161bacc3b15b4a19f95ef", + "RestApiId": { + "Ref": "MyApi" + }, + "StageName": "Stage" + } + }, + "PostRole": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "states.amazonaws.com" + ] + } + } + ] + }, + "ManagedPolicyArns": [ + "arn:aws:iam::aws:policy/AWSLambda_FullAccess" + ], + "Tags": [ + { + "Key": "stateMachine:createdBy", + "Value": "SAM" + } + ] + } + }, + "MyApiProdStage": { + "Type": "AWS::ApiGateway::Stage", + "Properties": { + "DeploymentId": { + "Ref": "MyApiDeployment5866b9014d" + }, + "RestApiId": { + "Ref": "MyApi" + }, + "StageName": "Prod" + } + }, + "Post": { + "Type": "AWS::StepFunctions::StateMachine", + "Properties": { + "DefinitionString": { + "Fn::Join": [ + "\n", + [ + "{", + " \"StartAt\": \"One\",", + " \"States\": {", + " \"One\": {", + " \"End\": true,", + " \"Resource\": \"${definition_substitution_1}\",", + " \"Type\": \"Task\"", + " }", + " }", + "}" + ] + ] + }, + "RoleArn": { + "Fn::GetAtt": [ + "PostRole", + "Arn" + ] + }, + "Tags": [ + { + "Key": "stateMachine:createdBy", + "Value": "SAM" + } + ], + "DefinitionSubstitutions": { + "definition_substitution_1": { + "Fn::GetAtt": [ + "HelloWorldFunction", + "Arn" + ] + } + } + } + }, + "PostPostEchoRole": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "apigateway.amazonaws.com" + ] + } + } + ] + }, + "Policies": [ + { + "PolicyName": "PostPostEchoRoleStartExecutionPolicy", + "PolicyDocument": { + "Statement": [ + { + "Action": "states:StartExecution", + "Effect": "Allow", + "Resource": { + "Ref": "Post" + } + } + ] + } + } + ] + } + }, + "HelloWorldFunction": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "def handler(event, context):\n print(event)\n return \"do nothing\"\n" + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "HelloWorldFunctionRole", + "Arn" + ] + }, + "Runtime": "python3.8", + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + } + }, + "MyApi": { + "Type": "AWS::ApiGateway::RestApi", + "Properties": { + "Body": { + "info": { + "version": "1.0", + "title": { + "Ref": "AWS::StackName" + } + }, + "paths": { + "/echo": { + "post": { + "x-amazon-apigateway-integration": { + "responses": { + "200": { + "statusCode": "200" + }, + "400": { + "statusCode": "400" + } + }, + "uri": { + "Fn::Sub": "arn:${AWS::Partition}:apigateway:${AWS::Region}:states:action/StartExecution" + }, + "httpMethod": "POST", + "requestTemplates": { + "application/json": { + "Fn::Sub": "{\"input\": \"$util.escapeJavaScript($input.json('$')).replaceAll(\"\\\\'\",\"'\")\", \"stateMachineArn\": \"${Post}\"}" + } + }, + "credentials": { + "Fn::GetAtt": [ + "PostPostEchoRole", + "Arn" + ] + }, + "type": "aws" + }, + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + } + }, + "swagger": "2.0" + }, + "Parameters": { + "endpointConfigurationTypes": "REGIONAL" + }, + "EndpointConfiguration": { + "Types": [ + "REGIONAL" + ] + } + } + } + } +} \ No newline at end of file diff --git a/tests/translator/output/aws-us-gov/state_machine_with_api.json b/tests/translator/output/aws-us-gov/state_machine_with_api.json new file mode 100644 index 000000000..589dbe88e --- /dev/null +++ b/tests/translator/output/aws-us-gov/state_machine_with_api.json @@ -0,0 +1,249 @@ +{ + "Resources": { + "HelloWorldFunctionRole": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "lambda.amazonaws.com" + ] + } + } + ] + }, + "ManagedPolicyArns": [ + "arn:aws-us-gov:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ], + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + } + }, + "MyApiDeployment5866b9014d": { + "Type": "AWS::ApiGateway::Deployment", + "Properties": { + "Description": "RestApi deployment id: 5866b9014d5a1c815da161bacc3b15b4a19f95ef", + "RestApiId": { + "Ref": "MyApi" + }, + "StageName": "Stage" + } + }, + "PostRole": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "states.amazonaws.com" + ] + } + } + ] + }, + "ManagedPolicyArns": [ + "arn:aws:iam::aws:policy/AWSLambda_FullAccess" + ], + "Tags": [ + { + "Key": "stateMachine:createdBy", + "Value": "SAM" + } + ] + } + }, + "MyApiProdStage": { + "Type": "AWS::ApiGateway::Stage", + "Properties": { + "DeploymentId": { + "Ref": "MyApiDeployment5866b9014d" + }, + "RestApiId": { + "Ref": "MyApi" + }, + "StageName": "Prod" + } + }, + "Post": { + "Type": "AWS::StepFunctions::StateMachine", + "Properties": { + "DefinitionString": { + "Fn::Join": [ + "\n", + [ + "{", + " \"StartAt\": \"One\",", + " \"States\": {", + " \"One\": {", + " \"End\": true,", + " \"Resource\": \"${definition_substitution_1}\",", + " \"Type\": \"Task\"", + " }", + " }", + "}" + ] + ] + }, + "RoleArn": { + "Fn::GetAtt": [ + "PostRole", + "Arn" + ] + }, + "Tags": [ + { + "Key": "stateMachine:createdBy", + "Value": "SAM" + } + ], + "DefinitionSubstitutions": { + "definition_substitution_1": { + "Fn::GetAtt": [ + "HelloWorldFunction", + "Arn" + ] + } + } + } + }, + "PostPostEchoRole": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "apigateway.amazonaws.com" + ] + } + } + ] + }, + "Policies": [ + { + "PolicyName": "PostPostEchoRoleStartExecutionPolicy", + "PolicyDocument": { + "Statement": [ + { + "Action": "states:StartExecution", + "Effect": "Allow", + "Resource": { + "Ref": "Post" + } + } + ] + } + } + ] + } + }, + "HelloWorldFunction": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "def handler(event, context):\n print(event)\n return \"do nothing\"\n" + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "HelloWorldFunctionRole", + "Arn" + ] + }, + "Runtime": "python3.8", + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + } + }, + "MyApi": { + "Type": "AWS::ApiGateway::RestApi", + "Properties": { + "Body": { + "info": { + "version": "1.0", + "title": { + "Ref": "AWS::StackName" + } + }, + "paths": { + "/echo": { + "post": { + "x-amazon-apigateway-integration": { + "responses": { + "200": { + "statusCode": "200" + }, + "400": { + "statusCode": "400" + } + }, + "uri": { + "Fn::Sub": "arn:${AWS::Partition}:apigateway:${AWS::Region}:states:action/StartExecution" + }, + "httpMethod": "POST", + "requestTemplates": { + "application/json": { + "Fn::Sub": "{\"input\": \"$util.escapeJavaScript($input.json('$')).replaceAll(\"\\\\'\",\"'\")\", \"stateMachineArn\": \"${Post}\"}" + } + }, + "credentials": { + "Fn::GetAtt": [ + "PostPostEchoRole", + "Arn" + ] + }, + "type": "aws" + }, + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + } + }, + "swagger": "2.0" + }, + "Parameters": { + "endpointConfigurationTypes": "REGIONAL" + }, + "EndpointConfiguration": { + "Types": [ + "REGIONAL" + ] + } + } + } + } +} \ No newline at end of file diff --git a/tests/translator/output/state_machine_with_api.json b/tests/translator/output/state_machine_with_api.json new file mode 100644 index 000000000..1b88fcf6a --- /dev/null +++ b/tests/translator/output/state_machine_with_api.json @@ -0,0 +1,241 @@ +{ + "Resources": { + "HelloWorldFunctionRole": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "lambda.amazonaws.com" + ] + } + } + ] + }, + "ManagedPolicyArns": [ + "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ], + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + } + }, + "MyApiDeployment5866b9014d": { + "Type": "AWS::ApiGateway::Deployment", + "Properties": { + "Description": "RestApi deployment id: 5866b9014d5a1c815da161bacc3b15b4a19f95ef", + "RestApiId": { + "Ref": "MyApi" + }, + "StageName": "Stage" + } + }, + "PostRole": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "states.amazonaws.com" + ] + } + } + ] + }, + "ManagedPolicyArns": [ + "arn:aws:iam::aws:policy/AWSLambda_FullAccess" + ], + "Tags": [ + { + "Key": "stateMachine:createdBy", + "Value": "SAM" + } + ] + } + }, + "MyApiProdStage": { + "Type": "AWS::ApiGateway::Stage", + "Properties": { + "DeploymentId": { + "Ref": "MyApiDeployment5866b9014d" + }, + "RestApiId": { + "Ref": "MyApi" + }, + "StageName": "Prod" + } + }, + "Post": { + "Type": "AWS::StepFunctions::StateMachine", + "Properties": { + "DefinitionString": { + "Fn::Join": [ + "\n", + [ + "{", + " \"StartAt\": \"One\",", + " \"States\": {", + " \"One\": {", + " \"End\": true,", + " \"Resource\": \"${definition_substitution_1}\",", + " \"Type\": \"Task\"", + " }", + " }", + "}" + ] + ] + }, + "RoleArn": { + "Fn::GetAtt": [ + "PostRole", + "Arn" + ] + }, + "Tags": [ + { + "Key": "stateMachine:createdBy", + "Value": "SAM" + } + ], + "DefinitionSubstitutions": { + "definition_substitution_1": { + "Fn::GetAtt": [ + "HelloWorldFunction", + "Arn" + ] + } + } + } + }, + "PostPostEchoRole": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "apigateway.amazonaws.com" + ] + } + } + ] + }, + "Policies": [ + { + "PolicyName": "PostPostEchoRoleStartExecutionPolicy", + "PolicyDocument": { + "Statement": [ + { + "Action": "states:StartExecution", + "Effect": "Allow", + "Resource": { + "Ref": "Post" + } + } + ] + } + } + ] + } + }, + "HelloWorldFunction": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "def handler(event, context):\n print(event)\n return \"do nothing\"\n" + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "HelloWorldFunctionRole", + "Arn" + ] + }, + "Runtime": "python3.8", + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + } + }, + "MyApi": { + "Type": "AWS::ApiGateway::RestApi", + "Properties": { + "Body": { + "info": { + "version": "1.0", + "title": { + "Ref": "AWS::StackName" + } + }, + "paths": { + "/echo": { + "post": { + "x-amazon-apigateway-integration": { + "responses": { + "200": { + "statusCode": "200" + }, + "400": { + "statusCode": "400" + } + }, + "uri": { + "Fn::Sub": "arn:${AWS::Partition}:apigateway:${AWS::Region}:states:action/StartExecution" + }, + "httpMethod": "POST", + "requestTemplates": { + "application/json": { + "Fn::Sub": "{\"input\": \"$util.escapeJavaScript($input.json('$')).replaceAll(\"\\\\'\",\"'\")\", \"stateMachineArn\": \"${Post}\"}" + } + }, + "credentials": { + "Fn::GetAtt": [ + "PostPostEchoRole", + "Arn" + ] + }, + "type": "aws" + }, + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request" + } + } + } + } + }, + "swagger": "2.0" + } + } + } + } +} \ No newline at end of file