diff --git a/moto/apigateway/models.py b/moto/apigateway/models.py index b6a8847db..ea0cecae1 100644 --- a/moto/apigateway/models.py +++ b/moto/apigateway/models.py @@ -16,7 +16,7 @@ try: except ImportError: from urllib.parse import urlparse import responses -from moto.core import ACCOUNT_ID, BaseBackend, BaseModel +from moto.core import ACCOUNT_ID, BaseBackend, BaseModel, CloudFormationModel from .utils import create_id from moto.core.utils import path_url from .exceptions import ( @@ -49,7 +49,7 @@ from ..core.models import responses_mock STAGE_URL = "https://{api_id}.execute-api.{region_name}.amazonaws.com/{stage_name}" -class Deployment(BaseModel, dict): +class Deployment(CloudFormationModel, dict): def __init__(self, deployment_id, name, description=""): super(Deployment, self).__init__() self["id"] = deployment_id @@ -57,6 +57,27 @@ class Deployment(BaseModel, dict): self["description"] = description self["createdDate"] = int(time.time()) + @staticmethod + def cloudformation_name_type(): + return "Deployment" + + @staticmethod + def cloudformation_type(): + return "AWS::ApiGateway::Deployment" + + @classmethod + def create_from_cloudformation_json( + cls, resource_name, cloudformation_json, region_name + ): + properties = cloudformation_json["Properties"] + rest_api_id = properties["RestApiId"] + name = properties["StageName"] + desc = properties.get("Description", "") + backend = apigateway_backends[region_name] + return backend.create_deployment( + function_id=rest_api_id, name=name, description=desc + ) + class IntegrationResponse(BaseModel, dict): def __init__( @@ -109,7 +130,7 @@ class MethodResponse(BaseModel, dict): self["statusCode"] = status_code -class Method(BaseModel, dict): +class Method(CloudFormationModel, dict): def __init__(self, method_type, authorization_type, **kwargs): super(Method, self).__init__() self.update( @@ -125,6 +146,45 @@ class Method(BaseModel, dict): ) self.method_responses = {} + @staticmethod + def cloudformation_name_type(): + return "Method" + + @staticmethod + def cloudformation_type(): + return "AWS::ApiGateway::Method" + + @classmethod + def create_from_cloudformation_json( + cls, resource_name, cloudformation_json, region_name + ): + properties = cloudformation_json["Properties"] + rest_api_id = properties["RestApiId"] + resource_id = properties["ResourceId"] + method_type = properties["HttpMethod"] + auth_type = properties["AuthorizationType"] + key_req = properties["ApiKeyRequired"] + backend = apigateway_backends[region_name] + m = backend.create_method( + function_id=rest_api_id, + resource_id=resource_id, + method_type=method_type, + authorization_type=auth_type, + api_key_required=key_req, + ) + int_method = properties["Integration"]["IntegrationHttpMethod"] + int_type = properties["Integration"]["Type"] + int_uri = properties["Integration"]["Uri"] + backend.create_integration( + function_id=rest_api_id, + resource_id=resource_id, + method_type=method_type, + integration_type=int_type, + uri=int_uri, + integration_method=int_method, + ) + return m + def create_response(self, response_code): method_response = MethodResponse(response_code) self.method_responses[response_code] = method_response @@ -137,8 +197,9 @@ class Method(BaseModel, dict): return self.method_responses.pop(response_code) -class Resource(BaseModel): +class Resource(CloudFormationModel): def __init__(self, id, region_name, api_id, path_part, parent_id): + super(Resource, self).__init__() self.id = id self.region_name = region_name self.api_id = api_id @@ -158,6 +219,39 @@ class Resource(BaseModel): response["pathPart"] = self.path_part return response + @property + def physical_resource_id(self): + return self.id + + @staticmethod + def cloudformation_name_type(): + return "Resource" + + @staticmethod + def cloudformation_type(): + return "AWS::ApiGateway::Resource" + + @classmethod + def create_from_cloudformation_json( + cls, resource_name, cloudformation_json, region_name + ): + properties = cloudformation_json["Properties"] + api_id = properties["RestApiId"] + parent = properties["ParentId"] + path = properties["PathPart"] + + backend = apigateway_backends[region_name] + if parent == api_id: + # A Root path (/) is automatically created. Any new paths should use this as their parent + resources = backend.list_resources(function_id=api_id) + root_id = [resource for resource in resources if resource.path_part == "/"][ + 0 + ].id + parent = root_id + return backend.create_resource( + function_id=api_id, parent_resource_id=parent, path_part=path + ) + def get_path(self): return self.get_parent_path() + self.path_part @@ -473,8 +567,9 @@ class UsagePlanKey(BaseModel, dict): self["value"] = value -class RestAPI(BaseModel): +class RestAPI(CloudFormationModel): def __init__(self, id, region_name, name, description, **kwargs): + super(RestAPI, self).__init__() self.id = id self.region_name = region_name self.name = name @@ -509,6 +604,38 @@ class RestAPI(BaseModel): "policy": self.policy, } + def get_cfn_attribute(self, attribute_name): + from moto.cloudformation.exceptions import UnformattedGetAttTemplateException + + if attribute_name == "RootResourceId": + return self.id + raise UnformattedGetAttTemplateException() + + @property + def physical_resource_id(self): + return self.id + + @staticmethod + def cloudformation_name_type(): + return "RestApi" + + @staticmethod + def cloudformation_type(): + return "AWS::ApiGateway::RestApi" + + @classmethod + def create_from_cloudformation_json( + cls, resource_name, cloudformation_json, region_name + ): + properties = cloudformation_json["Properties"] + name = properties["Name"] + desc = properties.get("Description", "") + config = properties.get("EndpointConfiguration", None) + backend = apigateway_backends[region_name] + return backend.create_rest_api( + name=name, description=desc, endpoint_configuration=config + ) + def add_child(self, path, parent_id=None): child_id = create_id() child = Resource( @@ -1003,7 +1130,8 @@ class APIGatewayBackend(BaseBackend): methods = [ list(res.resource_methods.values()) for res in self.list_resources(function_id) - ][0] + ] + methods = [m for sublist in methods for m in sublist] if not any(methods): raise NoMethodDefined() method_integrations = [ diff --git a/moto/apigateway/urls.py b/moto/apigateway/urls.py index 7e8de1398..5d0271f65 100644 --- a/moto/apigateway/urls.py +++ b/moto/apigateway/urls.py @@ -1,31 +1,33 @@ from __future__ import unicode_literals from .responses import APIGatewayResponse +response = APIGatewayResponse() + url_bases = ["https?://apigateway.(.+).amazonaws.com"] url_paths = { - "{0}/restapis$": APIGatewayResponse().restapis, - "{0}/restapis/(?P[^/]+)/?$": APIGatewayResponse().restapis_individual, - "{0}/restapis/(?P[^/]+)/resources$": APIGatewayResponse().resources, - "{0}/restapis/(?P[^/]+)/authorizers$": APIGatewayResponse().restapis_authorizers, - "{0}/restapis/(?P[^/]+)/authorizers/(?P[^/]+)/?$": APIGatewayResponse().authorizers, - "{0}/restapis/(?P[^/]+)/stages$": APIGatewayResponse().restapis_stages, - "{0}/restapis/(?P[^/]+)/stages/(?P[^/]+)/?$": APIGatewayResponse().stages, - "{0}/restapis/(?P[^/]+)/deployments$": APIGatewayResponse().deployments, - "{0}/restapis/(?P[^/]+)/deployments/(?P[^/]+)/?$": APIGatewayResponse().individual_deployment, - "{0}/restapis/(?P[^/]+)/resources/(?P[^/]+)/?$": APIGatewayResponse().resource_individual, - "{0}/restapis/(?P[^/]+)/resources/(?P[^/]+)/methods/(?P[^/]+)/?$": APIGatewayResponse().resource_methods, - r"{0}/restapis/(?P[^/]+)/resources/(?P[^/]+)/methods/(?P[^/]+)/responses/(?P\d+)$": APIGatewayResponse().resource_method_responses, - "{0}/restapis/(?P[^/]+)/resources/(?P[^/]+)/methods/(?P[^/]+)/integration/?$": APIGatewayResponse().integrations, - r"{0}/restapis/(?P[^/]+)/resources/(?P[^/]+)/methods/(?P[^/]+)/integration/responses/(?P\d+)/?$": APIGatewayResponse().integration_responses, - "{0}/apikeys$": APIGatewayResponse().apikeys, - "{0}/apikeys/(?P[^/]+)": APIGatewayResponse().apikey_individual, - "{0}/usageplans$": APIGatewayResponse().usage_plans, - "{0}/domainnames$": APIGatewayResponse().domain_names, - "{0}/restapis/(?P[^/]+)/models$": APIGatewayResponse().models, - "{0}/restapis/(?P[^/]+)/models/(?P[^/]+)/?$": APIGatewayResponse().model_induvidual, - "{0}/domainnames/(?P[^/]+)/?$": APIGatewayResponse().domain_name_induvidual, - "{0}/usageplans/(?P[^/]+)/?$": APIGatewayResponse().usage_plan_individual, - "{0}/usageplans/(?P[^/]+)/keys$": APIGatewayResponse().usage_plan_keys, - "{0}/usageplans/(?P[^/]+)/keys/(?P[^/]+)/?$": APIGatewayResponse().usage_plan_key_individual, + "{0}/restapis$": response.restapis, + "{0}/restapis/(?P[^/]+)/?$": response.restapis_individual, + "{0}/restapis/(?P[^/]+)/resources$": response.resources, + "{0}/restapis/(?P[^/]+)/authorizers$": response.restapis_authorizers, + "{0}/restapis/(?P[^/]+)/authorizers/(?P[^/]+)/?$": response.authorizers, + "{0}/restapis/(?P[^/]+)/stages$": response.restapis_stages, + "{0}/restapis/(?P[^/]+)/stages/(?P[^/]+)/?$": response.stages, + "{0}/restapis/(?P[^/]+)/deployments$": response.deployments, + "{0}/restapis/(?P[^/]+)/deployments/(?P[^/]+)/?$": response.individual_deployment, + "{0}/restapis/(?P[^/]+)/resources/(?P[^/]+)/?$": response.resource_individual, + "{0}/restapis/(?P[^/]+)/resources/(?P[^/]+)/methods/(?P[^/]+)/?$": response.resource_methods, + r"{0}/restapis/(?P[^/]+)/resources/(?P[^/]+)/methods/(?P[^/]+)/responses/(?P\d+)$": response.resource_method_responses, + "{0}/restapis/(?P[^/]+)/resources/(?P[^/]+)/methods/(?P[^/]+)/integration/?$": response.integrations, + r"{0}/restapis/(?P[^/]+)/resources/(?P[^/]+)/methods/(?P[^/]+)/integration/responses/(?P\d+)/?$": response.integration_responses, + "{0}/apikeys$": response.apikeys, + "{0}/apikeys/(?P[^/]+)": response.apikey_individual, + "{0}/usageplans$": response.usage_plans, + "{0}/domainnames$": response.domain_names, + "{0}/restapis/(?P[^/]+)/models$": response.models, + "{0}/restapis/(?P[^/]+)/models/(?P[^/]+)/?$": response.model_induvidual, + "{0}/domainnames/(?P[^/]+)/?$": response.domain_name_induvidual, + "{0}/usageplans/(?P[^/]+)/?$": response.usage_plan_individual, + "{0}/usageplans/(?P[^/]+)/keys$": response.usage_plan_keys, + "{0}/usageplans/(?P[^/]+)/keys/(?P[^/]+)/?$": response.usage_plan_key_individual, } diff --git a/moto/awslambda/models.py b/moto/awslambda/models.py index d331f71ef..b6c941cd7 100644 --- a/moto/awslambda/models.py +++ b/moto/awslambda/models.py @@ -185,6 +185,29 @@ def _validate_s3_bucket_and_key(data): return key +class Permission(CloudFormationModel): + def __init__(self, region): + self.region = region + + @staticmethod + def cloudformation_name_type(): + return "Permission" + + @staticmethod + def cloudformation_type(): + return "AWS::Lambda::Permission" + + @classmethod + def create_from_cloudformation_json( + cls, resource_name, cloudformation_json, region_name + ): + properties = cloudformation_json["Properties"] + backend = lambda_backends[region_name] + fn = backend.get_function(properties["FunctionName"]) + fn.policy.add_statement(raw=json.dumps(properties)) + return Permission(region=region_name) + + class LayerVersion(CloudFormationModel): def __init__(self, spec, region): # required @@ -316,7 +339,6 @@ class LambdaFunction(CloudFormationModel, DockerModel): self.layers = self._get_layers_data(spec.get("Layers", [])) self.logs_group_name = "/aws/lambda/{}".format(self.function_name) - self.logs_backend.ensure_log_group(self.logs_group_name, []) # this isn't finished yet. it needs to find out the VpcId value self._vpc_config = spec.get( @@ -341,6 +363,10 @@ class LambdaFunction(CloudFormationModel, DockerModel): self.code_bytes = key.value self.code_size = key.size self.code_sha_256 = hashlib.sha256(key.value).hexdigest() + else: + self.code_bytes = "" + self.code_size = 0 + self.code_sha_256 = "" self.function_arn = make_function_arn( self.region, ACCOUNT_ID, self.function_name @@ -510,6 +536,8 @@ class LambdaFunction(CloudFormationModel, DockerModel): return s def _invoke_lambda(self, code, event=None, context=None): + # Create the LogGroup if necessary, to write the result to + self.logs_backend.ensure_log_group(self.logs_group_name, []) # TODO: context not yet implemented if event is None: event = dict() diff --git a/moto/cloudformation/parsing.py b/moto/cloudformation/parsing.py index 50de876f3..33d6fa6d1 100644 --- a/moto/cloudformation/parsing.py +++ b/moto/cloudformation/parsing.py @@ -15,6 +15,7 @@ from moto.compat import collections_abc # the subclass's module hasn't been imported yet - then that subclass # doesn't exist yet, and __subclasses__ won't find it. # So we import here to populate the list of subclasses. +from moto.apigateway import models as apigateway_models # noqa from moto.autoscaling import models as autoscaling_models # noqa from moto.awslambda import models as awslambda_models # noqa from moto.batch import models as batch_models # noqa diff --git a/tests/test_apigateway/test_apigateway_cloudformation.py b/tests/test_apigateway/test_apigateway_cloudformation.py new file mode 100644 index 000000000..7938c6409 --- /dev/null +++ b/tests/test_apigateway/test_apigateway_cloudformation.py @@ -0,0 +1,350 @@ +import boto3 +import json +import sure # noqa + +from moto import mock_lambda, mock_cloudformation, mock_apigateway, mock_iam, mock_logs +from string import Template + +template = """{ + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "The AWS CloudFormation template for this Serverless application", + "Resources": { + "ServerlessDeploymentBucket": { + "Type": "AWS::S3::Bucket" + }, + "HelloLogGroup": { + "Type": "AWS::Logs::LogGroup", + "Properties": { + "LogGroupName": "/aws/lambda/timeseries-service-dev-hello" + } + }, + "IamRoleLambdaExecution": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": [ + "lambda.amazonaws.com" + ] + }, + "Action": [ + "sts:AssumeRole" + ] + } + ] + }, + "Policies": [ + { + "PolicyName": { + "Fn::Join": [ + "-", + [ + "dev", + "timeseries-service", + "lambda" + ] + ] + }, + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "logs:CreateLogStream" + ], + "Resource": [ + { + "Fn::Sub": "arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/timeseries-service-dev-hello:*" + } + ] + }, + { + "Effect": "Allow", + "Action": [ + "logs:PutLogEvents" + ], + "Resource": [ + { + "Fn::Sub": "arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/timeseries-service-dev-hello:*:*" + } + ] + } + ] + } + } + ], + "Path": "/", + "RoleName": { + "Fn::Join": [ + "-", + [ + "timeseries-service", + "dev", + "us-east-1", + "lambdaRole" + ] + ] + } + } + }, + "HelloLambdaFunction": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Ref": "ServerlessDeploymentBucket" + }, + "S3Key": "serverless/timeseries-service/dev/1542744572309-2018-11-20T20:09:32.309Z/timeseries-service.zip" + }, + "FunctionName": "timeseries-service-dev-hello", + "Handler": "handler.hello", + "MemorySize": 1024, + "Role": { + "Fn::GetAtt": [ + "IamRoleLambdaExecution", + "Arn" + ] + }, + "Runtime": "python2.7", + "Timeout": 6 + }, + "DependsOn": [ + "HelloLogGroup", + "IamRoleLambdaExecution" + ] + }, + "HelloLambdaVersionU88Ag36tX5K6Yuze3R8jedH2g7q2TTGuafWQxEnUmo": { + "Type": "AWS::Lambda::Version", + "DeletionPolicy": "Retain", + "Properties": { + "FunctionName": { + "Ref": "HelloLambdaFunction" + }, + "CodeSha256": "+pq+8RveA979z1DNF8UKnFGZfgE07blNyJGust5VJnU=" + } + }, + "ApiGatewayRestApi": { + "Type": "AWS::ApiGateway::RestApi", + "Properties": { + "Name": "dev-timeseries-service", + "EndpointConfiguration": { + "Types": [ + "EDGE" + ] + } + } + }, + "ApiGatewayResourceHello": { + "Type": "AWS::ApiGateway::Resource", + "Properties": { + "ParentId": { + "Fn::GetAtt": [ + "ApiGatewayRestApi", + "RootResourceId" + ] + }, + "PathPart": "hello", + "RestApiId": { + "Ref": "ApiGatewayRestApi" + } + } + }, + "ApiGatewayMethodHelloGet": { + "Type": "AWS::ApiGateway::Method", + "Properties": { + "HttpMethod": "GET", + "RequestParameters": {}, + "ResourceId": { + "Ref": "ApiGatewayResourceHello" + }, + "RestApiId": { + "Ref": "ApiGatewayRestApi" + }, + "ApiKeyRequired": false, + "AuthorizationType": "NONE", + "Integration": { + "IntegrationHttpMethod": "POST", + "Type": "AWS_PROXY", + "Uri": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":apigateway:", + { + "Ref": "AWS::Region" + }, + ":lambda:path/2015-03-31/functions/", + { + "Fn::GetAtt": [ + "HelloLambdaFunction", + "Arn" + ] + }, + "/invocations" + ] + ] + } + }, + "MethodResponses": [] + } + }, + "ApiGatewayDeployment1542744572805": { + "Type": "AWS::ApiGateway::Deployment", + "Properties": { + "RestApiId": { + "Ref": "ApiGatewayRestApi" + }, + "StageName": "dev" + }, + "DependsOn": [ + "ApiGatewayMethodHelloGet" + ] + }, + "HelloLambdaPermissionApiGateway": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "FunctionName": { + "Fn::GetAtt": [ + "HelloLambdaFunction", + "Arn" + ] + }, + "Action": "lambda:InvokeFunction", + "Principal": { + "Fn::Join": [ + "", + [ + "apigateway.", + { + "Ref": "AWS::URLSuffix" + } + ] + ] + }, + "SourceArn": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":execute-api:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":", + { + "Ref": "ApiGatewayRestApi" + }, + "/*/*" + ] + ] + } + } + } + }, + "Outputs": { + "ServerlessDeploymentBucketName": { + "Value": { + "Ref": "ServerlessDeploymentBucket" + } + }, + "HelloLambdaFunctionQualifiedArn": { + "Description": "Current Lambda function version", + "Value": { + "Ref": "HelloLambdaVersionU88Ag36tX5K6Yuze3R8jedH2g7q2TTGuafWQxEnUmo" + } + }, + "ServiceEndpoint": { + "Description": "URL of the service endpoint", + "Value": { + "Fn::Join": [ + "", + [ + "https://", + { + "Ref": "ApiGatewayRestApi" + }, + ".execute-api.us-east-1.", + { + "Ref": "AWS::URLSuffix" + }, + "/dev" + ] + ] + } + } + } +}""" + + +@mock_cloudformation +@mock_lambda +@mock_iam +@mock_logs +@mock_apigateway +def test_simple_apigateway_with_lambda_proxy(): + region = "us-east-1" + apigw = boto3.client("apigateway", region_name=region) + cf = boto3.client("cloudformation", region_name=region) + awslambda = boto3.client("lambda", region_name=region) + cf.create_stack(StackName="teststack", TemplateBody=template) + # + cf.describe_stacks(StackName="teststack")["Stacks"] + resources = cf.describe_stack_resources(StackName="teststack")["StackResources"] + api_id = [ + r["PhysicalResourceId"] + for r in resources + if r["ResourceType"] == "AWS::ApiGateway::RestApi" + ][0] + fn_name = [ + r["PhysicalResourceId"] + for r in resources + if r["LogicalResourceId"] == "HelloLambdaFunction" + ][0] + # + # Verify Rest API was created + api = apigw.get_rest_apis()["items"][0] + api["id"].should.equal(api_id) + api["name"].should.equal("dev-timeseries-service") + # + # Verify Gateway Resource was created + paths = apigw.get_resources(restApiId=api_id)["items"] + root_path = [p for p in paths if p["path"] == "/"][0] + hello_path = [p for p in paths if p["path"] == "/hello"][0] + hello_path["parentId"].should.equal(root_path["id"]) + # + # Verify Gateway Method was created + m = apigw.get_method( + restApiId=api_id, resourceId=hello_path["id"], httpMethod="GET" + ) + m["httpMethod"].should.equal("GET") + # + # Verify a Gateway Deployment was created + d = apigw.get_deployments(restApiId=api_id)["items"] + d.should.have.length_of(1) + # + # Verify Lambda function was created + awslambda.get_function(FunctionName=fn_name) # Will throw 404 if it doesn't exist + # + # Verify Lambda Permission was created + policy = json.loads(awslambda.get_policy(FunctionName=fn_name)["Policy"]) + statement = policy["Statement"][0] + statement["FunctionName"].should.contain(fn_name) + statement["Condition"]["ArnLike"]["AWS:SourceArn"].should.equal( + "arn:aws:execute-api:us-east-1:123456789012:{}/*/*".format(api_id) + ) diff --git a/tests/test_apigateway/test_server.py b/tests/test_apigateway/test_server.py index 9be948ef6..c191d1a05 100644 --- a/tests/test_apigateway/test_server.py +++ b/tests/test_apigateway/test_server.py @@ -14,7 +14,7 @@ def test_list_apis(): test_client = backend.test_client() res = test_client.get("/restapis") - res.data.should.equal(b'{"item": []}') + json.loads(res.data).should.contain("item") def test_usage_plans_apis():