diff --git a/samtranslator/model/api/http_api_generator.py b/samtranslator/model/api/http_api_generator.py index 25ba7a4bfc..91782a2be3 100644 --- a/samtranslator/model/api/http_api_generator.py +++ b/samtranslator/model/api/http_api_generator.py @@ -39,6 +39,7 @@ def __init__( depends_on: Optional[List[str]], definition_body: Optional[Dict[str, Any]], definition_uri: Optional[Intrinsicable[str]], + name: Optional[Any], stage_name: Optional[Intrinsicable[str]], tags: Optional[Dict[str, Intrinsicable[str]]] = None, auth: Optional[Dict[str, Intrinsicable[str]]] = None, @@ -60,6 +61,7 @@ def __init__( :param depends_on: Any resources that need to be depended on :param definition_body: API definition :param definition_uri: URI to API definition + :param name: Name of the API Gateway resource :param stage_name: Name of the Stage :param tags: Stage and API Tags :param access_log_settings: Whether to send access logs and where for Stage @@ -73,6 +75,7 @@ def __init__( self.definition_body = definition_body self.definition_uri = definition_uri self.stage_name = stage_name + self.name = name if not self.stage_name: self.stage_name = DefaultStageName self.auth = auth @@ -113,6 +116,7 @@ def _construct_http_api(self) -> ApiGatewayV2HttpApi: if self.disable_execute_api_endpoint is not None: self._add_endpoint_configuration() + self._add_title() self._add_description() if self.definition_uri: @@ -674,6 +678,27 @@ def _add_description(self) -> None: open_api_editor.add_description(self.description) self.definition_body = open_api_editor.openapi + def _add_title(self) -> None: + if not self.name: + return + + if not self.definition_body: + raise InvalidResourceException( + self.logical_id, + "Name works only with inline OpenApi specified in the 'DefinitionBody' property.", + ) + + if self.definition_body.get("info", {}).get("title") != OpenApiEditor._DEFAULT_OPENAPI_TITLE: + raise InvalidResourceException( + self.logical_id, + "Unable to set Name because it is already defined within inline OpenAPI specified in the " + "'DefinitionBody' property.", + ) + + open_api_editor = OpenApiEditor(self.definition_body) + open_api_editor.add_title(self.name) + self.definition_body = open_api_editor.openapi + @cw_timer(prefix="Generator", name="HttpApi") # type: ignore[misc] def to_cloudformation( self, route53_record_set_groups: Dict[str, Route53RecordSetGroup] diff --git a/samtranslator/model/sam_resources.py b/samtranslator/model/sam_resources.py index 314e898a22..7023e717a0 100644 --- a/samtranslator/model/sam_resources.py +++ b/samtranslator/model/sam_resources.py @@ -29,7 +29,14 @@ from .s3_utils.uri_parser import construct_s3_location_object, construct_image_code_object from .tags.resource_tagging import get_tag_list from samtranslator.metrics.method_decorator import cw_timer -from samtranslator.model import ResourceResolver, PropertyType, SamResourceMacro, Resource, ResourceTypeResolver +from samtranslator.model import ( + ResourceResolver, + Property, + PropertyType, + SamResourceMacro, + Resource, + ResourceTypeResolver, +) from samtranslator.model.apigateway import ( ApiGatewayDeployment, ApiGatewayStage, @@ -1276,6 +1283,7 @@ class SamHttpApi(SamResourceMacro): # In the future, we might rename and expose this property to customers so they can have SAM manage Explicit APIs # Swagger. "__MANAGE_SWAGGER": PropertyType(False, is_type(bool)), + "Name": Property(False, any_type()), "StageName": PropertyType(False, one_of(is_str(), is_type(dict))), "Tags": PropertyType(False, is_type(dict)), "DefinitionBody": PropertyType(False, is_type(dict)), @@ -1292,6 +1300,7 @@ class SamHttpApi(SamResourceMacro): "DisableExecuteApiEndpoint": PropertyType(False, is_type(bool)), } + Name: Optional[Any] StageName: Optional[Intrinsicable[str]] Tags: Optional[Dict[str, Any]] DefinitionBody: Optional[Dict[str, Any]] @@ -1332,6 +1341,7 @@ def to_cloudformation(self, **kwargs): # type: ignore[no-untyped-def] self.depends_on, self.DefinitionBody, self.DefinitionUri, + self.Name, self.StageName, tags=self.Tags, auth=self.Auth, diff --git a/samtranslator/open_api/open_api.py b/samtranslator/open_api/open_api.py index 96c5ae3409..1e1d3f0c8e 100644 --- a/samtranslator/open_api/open_api.py +++ b/samtranslator/open_api/open_api.py @@ -33,6 +33,7 @@ class OpenApiEditor(object): _X_ANY_METHOD = "x-amazon-apigateway-any-method" _ALL_HTTP_METHODS = ["OPTIONS", "GET", "HEAD", "POST", "PUT", "DELETE", "PATCH"] _DEFAULT_PATH = "$default" + _DEFAULT_OPENAPI_TITLE = ref("AWS::StackName") def __init__(self, doc: Optional[Dict[str, Any]]) -> None: """ @@ -604,6 +605,15 @@ def add_description(self, description: Intrinsicable[str]) -> None: return self.info["description"] = description + def add_title(self, title: Intrinsicable[str]) -> None: + """Add title in open api definition, if it is not already defined + + :param string description: Description of the API + """ + if self.info.get("title") != OpenApiEditor._DEFAULT_OPENAPI_TITLE: + return + self.info["title"] = title + def has_api_gateway_cors(self): # type: ignore[no-untyped-def] if self._doc.get(self._X_APIGW_CORS): return True @@ -660,7 +670,7 @@ def gen_skeleton() -> Py27Dict: skeleton["openapi"] = "3.0.1" skeleton["info"] = Py27Dict() skeleton["info"]["version"] = "1.0" - skeleton["info"]["title"] = ref("AWS::StackName") + skeleton["info"]["title"] = OpenApiEditor._DEFAULT_OPENAPI_TITLE skeleton["paths"] = Py27Dict() return skeleton diff --git a/tests/model/api/test_http_api_generator.py b/tests/model/api/test_http_api_generator.py index 0079524216..e892acce96 100644 --- a/tests/model/api/test_http_api_generator.py +++ b/tests/model/api/test_http_api_generator.py @@ -15,6 +15,7 @@ class TestHttpApiGenerator(TestCase): "depends_on": None, "definition_body": None, "definition_uri": None, + "name": None, "stage_name": None, "tags": None, "auth": None, @@ -208,6 +209,7 @@ class TestCustomDomains(TestCase): "depends_on": None, "definition_body": None, "definition_uri": "s3://bucket/key", + "name": None, "stage_name": None, "tags": None, "auth": None, diff --git a/tests/translator/input/explicit_http_api_with_name.yaml b/tests/translator/input/explicit_http_api_with_name.yaml new file mode 100644 index 0000000000..e4b15ff100 --- /dev/null +++ b/tests/translator/input/explicit_http_api_with_name.yaml @@ -0,0 +1,26 @@ +Resources: + HttpApiFunction: + Type: AWS::Serverless::Function + Properties: + CodeUri: s3://sam-demo-bucket/todo_list.zip + Handler: index.restapi + Runtime: python3.7 + Events: + SimpleCase: + Type: HttpApi + Properties: + ApiId: !Ref MyApi + SimpleCase2: + Type: HttpApi + Properties: + ApiId: !Ref MyApiWithIntrinsicName + + MyApi: + Type: AWS::Serverless::HttpApi + Properties: + Name: MyHttpApi + + MyApiWithIntrinsicName: + Type: AWS::Serverless::HttpApi + Properties: + Name: !Sub "${HttpApiFunction}-HttpApi" diff --git a/tests/translator/output/aws-cn/explicit_http_api_with_name.json b/tests/translator/output/aws-cn/explicit_http_api_with_name.json new file mode 100644 index 0000000000..095887590e --- /dev/null +++ b/tests/translator/output/aws-cn/explicit_http_api_with_name.json @@ -0,0 +1,195 @@ +{ + "Resources": { + "HttpApiFunction": { + "Properties": { + "Code": { + "S3Bucket": "sam-demo-bucket", + "S3Key": "todo_list.zip" + }, + "Handler": "index.restapi", + "Role": { + "Fn::GetAtt": [ + "HttpApiFunctionRole", + "Arn" + ] + }, + "Runtime": "python3.7", + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + }, + "Type": "AWS::Lambda::Function" + }, + "HttpApiFunctionRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "lambda.amazonaws.com" + ] + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + "arn:aws-cn:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ], + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + }, + "Type": "AWS::IAM::Role" + }, + "HttpApiFunctionSimpleCase2Permission": { + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Ref": "HttpApiFunction" + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Sub": [ + "arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${__ApiId__}/${__Stage__}/*", + { + "__ApiId__": { + "Ref": "MyApiWithIntrinsicName" + }, + "__Stage__": "*" + } + ] + } + }, + "Type": "AWS::Lambda::Permission" + }, + "HttpApiFunctionSimpleCasePermission": { + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Ref": "HttpApiFunction" + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Sub": [ + "arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${__ApiId__}/${__Stage__}/*", + { + "__ApiId__": { + "Ref": "MyApi" + }, + "__Stage__": "*" + } + ] + } + }, + "Type": "AWS::Lambda::Permission" + }, + "MyApi": { + "Properties": { + "Body": { + "info": { + "title": "MyHttpApi", + "version": "1.0" + }, + "openapi": "3.0.1", + "paths": { + "$default": { + "x-amazon-apigateway-any-method": { + "isDefaultRoute": true, + "responses": {}, + "x-amazon-apigateway-integration": { + "httpMethod": "POST", + "payloadFormatVersion": "2.0", + "type": "aws_proxy", + "uri": { + "Fn::Sub": "arn:${AWS::Partition}:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${HttpApiFunction.Arn}/invocations" + } + } + } + } + }, + "tags": [ + { + "name": "httpapi:createdBy", + "x-amazon-apigateway-tag-value": "SAM" + } + ] + } + }, + "Type": "AWS::ApiGatewayV2::Api" + }, + "MyApiApiGatewayDefaultStage": { + "Properties": { + "ApiId": { + "Ref": "MyApi" + }, + "AutoDeploy": true, + "StageName": "$default", + "Tags": { + "httpapi:createdBy": "SAM" + } + }, + "Type": "AWS::ApiGatewayV2::Stage" + }, + "MyApiWithIntrinsicName": { + "Properties": { + "Body": { + "info": { + "title": { + "Fn::Sub": "${HttpApiFunction}-HttpApi" + }, + "version": "1.0" + }, + "openapi": "3.0.1", + "paths": { + "$default": { + "x-amazon-apigateway-any-method": { + "isDefaultRoute": true, + "responses": {}, + "x-amazon-apigateway-integration": { + "httpMethod": "POST", + "payloadFormatVersion": "2.0", + "type": "aws_proxy", + "uri": { + "Fn::Sub": "arn:${AWS::Partition}:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${HttpApiFunction.Arn}/invocations" + } + } + } + } + }, + "tags": [ + { + "name": "httpapi:createdBy", + "x-amazon-apigateway-tag-value": "SAM" + } + ] + } + }, + "Type": "AWS::ApiGatewayV2::Api" + }, + "MyApiWithIntrinsicNameApiGatewayDefaultStage": { + "Properties": { + "ApiId": { + "Ref": "MyApiWithIntrinsicName" + }, + "AutoDeploy": true, + "StageName": "$default", + "Tags": { + "httpapi:createdBy": "SAM" + } + }, + "Type": "AWS::ApiGatewayV2::Stage" + } + } +} diff --git a/tests/translator/output/aws-us-gov/explicit_http_api_with_name.json b/tests/translator/output/aws-us-gov/explicit_http_api_with_name.json new file mode 100644 index 0000000000..f9fccb3989 --- /dev/null +++ b/tests/translator/output/aws-us-gov/explicit_http_api_with_name.json @@ -0,0 +1,195 @@ +{ + "Resources": { + "HttpApiFunction": { + "Properties": { + "Code": { + "S3Bucket": "sam-demo-bucket", + "S3Key": "todo_list.zip" + }, + "Handler": "index.restapi", + "Role": { + "Fn::GetAtt": [ + "HttpApiFunctionRole", + "Arn" + ] + }, + "Runtime": "python3.7", + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + }, + "Type": "AWS::Lambda::Function" + }, + "HttpApiFunctionRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "lambda.amazonaws.com" + ] + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + "arn:aws-us-gov:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ], + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + }, + "Type": "AWS::IAM::Role" + }, + "HttpApiFunctionSimpleCase2Permission": { + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Ref": "HttpApiFunction" + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Sub": [ + "arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${__ApiId__}/${__Stage__}/*", + { + "__ApiId__": { + "Ref": "MyApiWithIntrinsicName" + }, + "__Stage__": "*" + } + ] + } + }, + "Type": "AWS::Lambda::Permission" + }, + "HttpApiFunctionSimpleCasePermission": { + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Ref": "HttpApiFunction" + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Sub": [ + "arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${__ApiId__}/${__Stage__}/*", + { + "__ApiId__": { + "Ref": "MyApi" + }, + "__Stage__": "*" + } + ] + } + }, + "Type": "AWS::Lambda::Permission" + }, + "MyApi": { + "Properties": { + "Body": { + "info": { + "title": "MyHttpApi", + "version": "1.0" + }, + "openapi": "3.0.1", + "paths": { + "$default": { + "x-amazon-apigateway-any-method": { + "isDefaultRoute": true, + "responses": {}, + "x-amazon-apigateway-integration": { + "httpMethod": "POST", + "payloadFormatVersion": "2.0", + "type": "aws_proxy", + "uri": { + "Fn::Sub": "arn:${AWS::Partition}:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${HttpApiFunction.Arn}/invocations" + } + } + } + } + }, + "tags": [ + { + "name": "httpapi:createdBy", + "x-amazon-apigateway-tag-value": "SAM" + } + ] + } + }, + "Type": "AWS::ApiGatewayV2::Api" + }, + "MyApiApiGatewayDefaultStage": { + "Properties": { + "ApiId": { + "Ref": "MyApi" + }, + "AutoDeploy": true, + "StageName": "$default", + "Tags": { + "httpapi:createdBy": "SAM" + } + }, + "Type": "AWS::ApiGatewayV2::Stage" + }, + "MyApiWithIntrinsicName": { + "Properties": { + "Body": { + "info": { + "title": { + "Fn::Sub": "${HttpApiFunction}-HttpApi" + }, + "version": "1.0" + }, + "openapi": "3.0.1", + "paths": { + "$default": { + "x-amazon-apigateway-any-method": { + "isDefaultRoute": true, + "responses": {}, + "x-amazon-apigateway-integration": { + "httpMethod": "POST", + "payloadFormatVersion": "2.0", + "type": "aws_proxy", + "uri": { + "Fn::Sub": "arn:${AWS::Partition}:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${HttpApiFunction.Arn}/invocations" + } + } + } + } + }, + "tags": [ + { + "name": "httpapi:createdBy", + "x-amazon-apigateway-tag-value": "SAM" + } + ] + } + }, + "Type": "AWS::ApiGatewayV2::Api" + }, + "MyApiWithIntrinsicNameApiGatewayDefaultStage": { + "Properties": { + "ApiId": { + "Ref": "MyApiWithIntrinsicName" + }, + "AutoDeploy": true, + "StageName": "$default", + "Tags": { + "httpapi:createdBy": "SAM" + } + }, + "Type": "AWS::ApiGatewayV2::Stage" + } + } +} diff --git a/tests/translator/output/explicit_http_api_with_name.json b/tests/translator/output/explicit_http_api_with_name.json new file mode 100644 index 0000000000..64525305b1 --- /dev/null +++ b/tests/translator/output/explicit_http_api_with_name.json @@ -0,0 +1,195 @@ +{ + "Resources": { + "HttpApiFunction": { + "Properties": { + "Code": { + "S3Bucket": "sam-demo-bucket", + "S3Key": "todo_list.zip" + }, + "Handler": "index.restapi", + "Role": { + "Fn::GetAtt": [ + "HttpApiFunctionRole", + "Arn" + ] + }, + "Runtime": "python3.7", + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + }, + "Type": "AWS::Lambda::Function" + }, + "HttpApiFunctionRole": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "lambda.amazonaws.com" + ] + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ], + "Tags": [ + { + "Key": "lambda:createdBy", + "Value": "SAM" + } + ] + }, + "Type": "AWS::IAM::Role" + }, + "HttpApiFunctionSimpleCase2Permission": { + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Ref": "HttpApiFunction" + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Sub": [ + "arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${__ApiId__}/${__Stage__}/*", + { + "__ApiId__": { + "Ref": "MyApiWithIntrinsicName" + }, + "__Stage__": "*" + } + ] + } + }, + "Type": "AWS::Lambda::Permission" + }, + "HttpApiFunctionSimpleCasePermission": { + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Ref": "HttpApiFunction" + }, + "Principal": "apigateway.amazonaws.com", + "SourceArn": { + "Fn::Sub": [ + "arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${__ApiId__}/${__Stage__}/*", + { + "__ApiId__": { + "Ref": "MyApi" + }, + "__Stage__": "*" + } + ] + } + }, + "Type": "AWS::Lambda::Permission" + }, + "MyApi": { + "Properties": { + "Body": { + "info": { + "title": "MyHttpApi", + "version": "1.0" + }, + "openapi": "3.0.1", + "paths": { + "$default": { + "x-amazon-apigateway-any-method": { + "isDefaultRoute": true, + "responses": {}, + "x-amazon-apigateway-integration": { + "httpMethod": "POST", + "payloadFormatVersion": "2.0", + "type": "aws_proxy", + "uri": { + "Fn::Sub": "arn:${AWS::Partition}:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${HttpApiFunction.Arn}/invocations" + } + } + } + } + }, + "tags": [ + { + "name": "httpapi:createdBy", + "x-amazon-apigateway-tag-value": "SAM" + } + ] + } + }, + "Type": "AWS::ApiGatewayV2::Api" + }, + "MyApiApiGatewayDefaultStage": { + "Properties": { + "ApiId": { + "Ref": "MyApi" + }, + "AutoDeploy": true, + "StageName": "$default", + "Tags": { + "httpapi:createdBy": "SAM" + } + }, + "Type": "AWS::ApiGatewayV2::Stage" + }, + "MyApiWithIntrinsicName": { + "Properties": { + "Body": { + "info": { + "title": { + "Fn::Sub": "${HttpApiFunction}-HttpApi" + }, + "version": "1.0" + }, + "openapi": "3.0.1", + "paths": { + "$default": { + "x-amazon-apigateway-any-method": { + "isDefaultRoute": true, + "responses": {}, + "x-amazon-apigateway-integration": { + "httpMethod": "POST", + "payloadFormatVersion": "2.0", + "type": "aws_proxy", + "uri": { + "Fn::Sub": "arn:${AWS::Partition}:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${HttpApiFunction.Arn}/invocations" + } + } + } + } + }, + "tags": [ + { + "name": "httpapi:createdBy", + "x-amazon-apigateway-tag-value": "SAM" + } + ] + } + }, + "Type": "AWS::ApiGatewayV2::Api" + }, + "MyApiWithIntrinsicNameApiGatewayDefaultStage": { + "Properties": { + "ApiId": { + "Ref": "MyApiWithIntrinsicName" + }, + "AutoDeploy": true, + "StageName": "$default", + "Tags": { + "httpapi:createdBy": "SAM" + } + }, + "Type": "AWS::ApiGatewayV2::Stage" + } + } +} diff --git a/tests/translator/test_translator.py b/tests/translator/test_translator.py index a0b102879a..0581b278f3 100644 --- a/tests/translator/test_translator.py +++ b/tests/translator/test_translator.py @@ -314,6 +314,7 @@ def test_transform_success(self, testcase, partition_with_region): "http_api_explicit_stage", "http_api_def_uri", "explicit_http_api", + "explicit_http_api_with_name", "http_api_custom_iam_auth", "http_api_with_cors", "http_api_description",