Skip to content

Commit

Permalink
feat(start-api): CloudFormation AWS::ApiGateway::RestApi support (#1238)
Browse files Browse the repository at this point in the history
  • Loading branch information
viksrivat authored and jfuss committed Aug 7, 2019
1 parent 65b6f53 commit 2368c33
Show file tree
Hide file tree
Showing 14 changed files with 1,218 additions and 459 deletions.
215 changes: 215 additions & 0 deletions samcli/commands/local/lib/api_collector.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
"""
Class to store the API configurations in the SAM Template. This class helps store both implicit and explicit
APIs in a standardized format
"""

import logging
from collections import namedtuple

from six import string_types

LOG = logging.getLogger(__name__)


class ApiCollector(object):
# Properties of each API. The structure is quite similar to the properties of AWS::Serverless::Api resource.
# This is intentional because it allows us to easily extend this class to support future properties on the API.
# We will store properties of Implicit APIs also in this format which converges the handling of implicit & explicit
# APIs.
Properties = namedtuple("Properties", ["apis", "binary_media_types", "cors", "stage_name", "stage_variables"])

def __init__(self):
# API properties stored per resource. Key is the LogicalId of the AWS::Serverless::Api resource and
# value is the properties
self.by_resource = {}

def __iter__(self):
"""
Iterator to iterate through all the APIs stored in the collector. In each iteration, this yields the
LogicalId of the API resource and a list of APIs available in this resource.
Yields
-------
str
LogicalID of the AWS::Serverless::Api resource
list samcli.commands.local.lib.provider.Api
List of the API available in this resource along with additional configuration like binary media types.
"""

for logical_id, _ in self.by_resource.items():
yield logical_id, self._get_apis_with_config(logical_id)

def add_apis(self, logical_id, apis):
"""
Stores the given APIs tagged under the given logicalId
Parameters
----------
logical_id : str
LogicalId of the AWS::Serverless::Api resource
apis : list of samcli.commands.local.lib.provider.Api
List of APIs available in this resource
"""
properties = self._get_properties(logical_id)
properties.apis.extend(apis)

def add_binary_media_types(self, logical_id, binary_media_types):
"""
Stores the binary media type configuration for the API with given logical ID
Parameters
----------
logical_id : str
LogicalId of the AWS::Serverless::Api resource
binary_media_types : list of str
List of binary media types supported by this resource
"""
properties = self._get_properties(logical_id)

binary_media_types = binary_media_types or []
for value in binary_media_types:
normalized_value = self._normalize_binary_media_type(value)

# If the value is not supported, then just skip it.
if normalized_value:
properties.binary_media_types.add(normalized_value)
else:
LOG.debug("Unsupported data type of binary media type value of resource '%s'", logical_id)

def add_stage_name(self, logical_id, stage_name):
"""
Stores the stage name for the API with the given local ID
Parameters
----------
logical_id : str
LogicalId of the AWS::Serverless::Api resource
stage_name : str
The stage_name string
"""
properties = self._get_properties(logical_id)
properties = properties._replace(stage_name=stage_name)
self._set_properties(logical_id, properties)

def add_stage_variables(self, logical_id, stage_variables):
"""
Stores the stage variables for the API with the given local ID
Parameters
----------
logical_id : str
LogicalId of the AWS::Serverless::Api resource
stage_variables : dict
A dictionary containing stage variables.
"""
properties = self._get_properties(logical_id)
properties = properties._replace(stage_variables=stage_variables)
self._set_properties(logical_id, properties)

def _get_apis_with_config(self, logical_id):
"""
Returns the list of APIs in this resource along with other extra configuration such as binary media types,
cors etc. Additional configuration is merged directly into the API data because these properties, although
defined globally, actually apply to each API.
Parameters
----------
logical_id : str
Logical ID of the resource to fetch data for
Returns
-------
list of samcli.commands.local.lib.provider.Api
List of APIs with additional configurations for the resource with given logicalId. If there are no APIs,
then it returns an empty list
"""

properties = self._get_properties(logical_id)

# These configs need to be applied to each API
binary_media = sorted(list(properties.binary_media_types)) # Also sort the list to keep the ordering stable
cors = properties.cors
stage_name = properties.stage_name
stage_variables = properties.stage_variables

result = []
for api in properties.apis:
# Create a copy of the API with updated configuration
updated_api = api._replace(binary_media_types=binary_media,
cors=cors,
stage_name=stage_name,
stage_variables=stage_variables)
result.append(updated_api)

return result

def _get_properties(self, logical_id):
"""
Returns the properties of resource with given logical ID. If a resource is not found, then it returns an
empty data.
Parameters
----------
logical_id : str
Logical ID of the resource
Returns
-------
samcli.commands.local.lib.sam_api_provider.ApiCollector.Properties
Properties object for this resource.
"""

if logical_id not in self.by_resource:
self.by_resource[logical_id] = self.Properties(apis=[],
# Use a set() to be able to easily de-dupe
binary_media_types=set(),
cors=None,
stage_name=None,
stage_variables=None)

return self.by_resource[logical_id]

def _set_properties(self, logical_id, properties):
"""
Sets the properties of resource with given logical ID. If a resource is not found, it does nothing
Parameters
----------
logical_id : str
Logical ID of the resource
properties : samcli.commands.local.lib.sam_api_provider.ApiCollector.Properties
Properties object for this resource.
"""

if logical_id in self.by_resource:
self.by_resource[logical_id] = properties

@staticmethod
def _normalize_binary_media_type(value):
"""
Converts binary media types values to the canonical format. Ex: image~1gif -> image/gif. If the value is not
a string, then this method just returns None
Parameters
----------
value : str
Value to be normalized
Returns
-------
str or None
Normalized value. If the input was not a string, then None is returned
"""

if not isinstance(value, string_types):
# It is possible that user specified a dict value for one of the binary media types. We just skip them
return None

return value.replace("~1", "/")
94 changes: 94 additions & 0 deletions samcli/commands/local/lib/api_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""Class that provides Apis from a SAM Template"""

import logging

from samcli.commands.local.lib.cfn_base_api_provider import CfnBaseApiProvider
from samcli.commands.local.lib.api_collector import ApiCollector
from samcli.commands.local.lib.provider import AbstractApiProvider
from samcli.commands.local.lib.sam_base_provider import SamBaseProvider
from samcli.commands.local.lib.sam_api_provider import SamApiProvider
from samcli.commands.local.lib.cfn_api_provider import CfnApiProvider

LOG = logging.getLogger(__name__)


class ApiProvider(AbstractApiProvider):

def __init__(self, template_dict, parameter_overrides=None, cwd=None):
"""
Initialize the class with SAM template data. The template_dict (SAM Templated) is assumed
to be valid, normalized and a dictionary. template_dict should be normalized by running any and all
pre-processing before passing to this class.
This class does not perform any syntactic validation of the template.
After the class is initialized, changes to ``template_dict`` will not be reflected in here.
You will need to explicitly update the class with new template, if necessary.
Parameters
----------
template_dict : dict
SAM Template as a dictionary
cwd : str
Optional working directory with respect to which we will resolve relative path to Swagger file
"""
self.template_dict = SamBaseProvider.get_template(template_dict, parameter_overrides)
self.resources = self.template_dict.get("Resources", {})

LOG.debug("%d resources found in the template", len(self.resources))

# Store a set of apis
self.cwd = cwd
self.apis = self._extract_apis(self.resources)

LOG.debug("%d APIs found in the template", len(self.apis))

def get_all(self):
"""
Yields all the Lambda functions with Api Events available in the SAM Template.
:yields Api: namedtuple containing the Api information
"""

for api in self.apis:
yield api

def _extract_apis(self, resources):
"""
Extracts all the Apis by running through the one providers. The provider that has the first type matched
will be run across all the resources
Parameters
----------
resources: dict
The dictionary containing the different resources within the template
Returns
---------
list of Apis extracted from the resources
"""
collector = ApiCollector()
provider = self.find_api_provider(resources)
apis = provider.extract_resource_api(resources, collector, cwd=self.cwd)
return self.normalize_apis(apis)

@staticmethod
def find_api_provider(resources):
"""
Finds the ApiProvider given the first api type of the resource
Parameters
-----------
resources: dict
The dictionary containing the different resources within the template
Return
----------
Instance of the ApiProvider that will be run on the template with a default of SamApiProvider
"""
for _, resource in resources.items():
if resource.get(CfnBaseApiProvider.RESOURCE_TYPE) in SamApiProvider.TYPES:
return SamApiProvider()
elif resource.get(CfnBaseApiProvider.RESOURCE_TYPE) in CfnApiProvider.TYPES:
return CfnApiProvider()

return SamApiProvider()
69 changes: 69 additions & 0 deletions samcli/commands/local/lib/cfn_api_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""Parses SAM given a template"""
import logging

from samcli.commands.local.lib.cfn_base_api_provider import CfnBaseApiProvider

LOG = logging.getLogger(__name__)


class CfnApiProvider(CfnBaseApiProvider):
APIGATEWAY_RESTAPI = "AWS::ApiGateway::RestApi"
TYPES = [
APIGATEWAY_RESTAPI
]

def extract_resource_api(self, resources, collector, cwd=None):
"""
Extract the Api Object from a given resource and adds it to the ApiCollector.
Parameters
----------
resources: dict
The dictionary containing the different resources within the template
collector: ApiCollector
Instance of the API collector that where we will save the API information
cwd : str
Optional working directory with respect to which we will resolve relative path to Swagger file
Return
-------
Returns a list of Apis
"""
for logical_id, resource in resources.items():
resource_type = resource.get(CfnBaseApiProvider.RESOURCE_TYPE)
if resource_type == CfnApiProvider.APIGATEWAY_RESTAPI:
self._extract_cloud_formation_api(logical_id, resource, collector, cwd)
all_apis = []
for _, apis in collector:
all_apis.extend(apis)
return all_apis

def _extract_cloud_formation_api(self, logical_id, api_resource, collector, cwd=None):
"""
Extract APIs from AWS::ApiGateway::RestApi resource by reading and parsing Swagger documents. The result is
added to the collector.
Parameters
----------
logical_id : str
Logical ID of the resource
api_resource : dict
Resource definition, including its properties
collector : ApiCollector
Instance of the API collector that where we will save the API information
"""
properties = api_resource.get("Properties", {})
body = properties.get("Body")
body_s3_location = properties.get("BodyS3Location")
binary_media = properties.get("BinaryMediaTypes", [])

if not body and not body_s3_location:
# Swagger is not found anywhere.
LOG.debug("Skipping resource '%s'. Swagger document not found in Body and BodyS3Location",
logical_id)
return
self.extract_swagger_api(logical_id, body, body_s3_location, binary_media, collector, cwd)
Loading

0 comments on commit 2368c33

Please sign in to comment.