Skip to content

Commit 421d15e

Browse files
authored
Merge pull request #1320 from awslabs/start-api/cfn
feat(start-api): Support for RestApi and Stage of CloudFormation resources
2 parents 2d95111 + cf45ab8 commit 421d15e

21 files changed

+1903
-1040
lines changed
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
"""
2+
Class to store the API configurations in the SAM Template. This class helps store both implicit and explicit
3+
routes in a standardized format
4+
"""
5+
6+
import logging
7+
from collections import defaultdict
8+
9+
from six import string_types
10+
11+
from samcli.local.apigw.local_apigw_service import Route
12+
from samcli.commands.local.lib.provider import Api
13+
14+
LOG = logging.getLogger(__name__)
15+
16+
17+
class ApiCollector(object):
18+
19+
def __init__(self):
20+
# Route properties stored per resource.
21+
self._route_per_resource = defaultdict(list)
22+
23+
# processed values to be set before creating the api
24+
self._routes = []
25+
self.binary_media_types_set = set()
26+
self.stage_name = None
27+
self.stage_variables = None
28+
29+
def __iter__(self):
30+
"""
31+
Iterator to iterate through all the routes stored in the collector. In each iteration, this yields the
32+
LogicalId of the route resource and a list of routes available in this resource.
33+
Yields
34+
-------
35+
str
36+
LogicalID of the AWS::Serverless::Api or AWS::ApiGateway::RestApi resource
37+
list samcli.commands.local.lib.provider.Api
38+
List of the API available in this resource along with additional configuration like binary media types.
39+
"""
40+
41+
for logical_id, _ in self._route_per_resource.items():
42+
yield logical_id, self._get_routes(logical_id)
43+
44+
def add_routes(self, logical_id, routes):
45+
"""
46+
Stores the given routes tagged under the given logicalId
47+
Parameters
48+
----------
49+
logical_id : str
50+
LogicalId of the AWS::Serverless::Api or AWS::ApiGateway::RestApi resource
51+
routes : list of samcli.commands.local.agiw.local_apigw_service.Route
52+
List of routes available in this resource
53+
"""
54+
self._get_routes(logical_id).extend(routes)
55+
56+
def _get_routes(self, logical_id):
57+
"""
58+
Returns the properties of resource with given logical ID. If a resource is not found, then it returns an
59+
empty data.
60+
Parameters
61+
----------
62+
logical_id : str
63+
Logical ID of the resource
64+
Returns
65+
-------
66+
samcli.commands.local.lib.Routes
67+
Properties object for this resource.
68+
"""
69+
70+
return self._route_per_resource[logical_id]
71+
72+
@property
73+
def routes(self):
74+
return self._routes if self._routes else self.all_routes()
75+
76+
@routes.setter
77+
def routes(self, routes):
78+
self._routes = routes
79+
80+
def all_routes(self):
81+
"""
82+
Gets all the routes within the _route_per_resource
83+
84+
Return
85+
-------
86+
All the routes within the _route_per_resource
87+
"""
88+
routes = []
89+
for logical_id in self._route_per_resource.keys():
90+
routes.extend(self._get_routes(logical_id))
91+
return routes
92+
93+
def get_api(self):
94+
"""
95+
Creates the api using the parts from the ApiCollector. The routes are also deduped so that there is no
96+
duplicate routes with the same function name, path, but different method.
97+
98+
The normalised_routes are the routes that have been processed. By default, this will get all the routes.
99+
However, it can be changed to override the default value of normalised routes such as in SamApiProvider
100+
101+
Return
102+
-------
103+
An Api object with all the properties
104+
"""
105+
api = Api()
106+
api.routes = self.dedupe_function_routes(self.routes)
107+
api.binary_media_types_set = self.binary_media_types_set
108+
api.stage_name = self.stage_name
109+
api.stage_variables = self.stage_variables
110+
return api
111+
112+
@staticmethod
113+
def dedupe_function_routes(routes):
114+
"""
115+
Remove duplicate routes that have the same function_name and method
116+
117+
route: list(Route)
118+
List of Routes
119+
120+
Return
121+
-------
122+
A list of routes without duplicate routes with the same function_name and method
123+
"""
124+
grouped_routes = {}
125+
126+
for route in routes:
127+
key = "{}-{}".format(route.function_name, route.path)
128+
config = grouped_routes.get(key, None)
129+
methods = route.methods
130+
if config:
131+
methods += config.methods
132+
sorted_methods = sorted(methods)
133+
grouped_routes[key] = Route(function_name=route.function_name, path=route.path, methods=sorted_methods)
134+
return list(grouped_routes.values())
135+
136+
def add_binary_media_types(self, logical_id, binary_media_types):
137+
"""
138+
Stores the binary media type configuration for the API with given logical ID
139+
Parameters
140+
----------
141+
142+
logical_id : str
143+
LogicalId of the AWS::Serverless::Api resource
144+
145+
api: samcli.commands.local.lib.provider.Api
146+
Instance of the Api which will save all the api configurations
147+
148+
binary_media_types : list of str
149+
List of binary media types supported by this resource
150+
"""
151+
152+
binary_media_types = binary_media_types or []
153+
for value in binary_media_types:
154+
normalized_value = self.normalize_binary_media_type(value)
155+
156+
# If the value is not supported, then just skip it.
157+
if normalized_value:
158+
self.binary_media_types_set.add(normalized_value)
159+
else:
160+
LOG.debug("Unsupported data type of binary media type value of resource '%s'", logical_id)
161+
162+
@staticmethod
163+
def normalize_binary_media_type(value):
164+
"""
165+
Converts binary media types values to the canonical format. Ex: image~1gif -> image/gif. If the value is not
166+
a string, then this method just returns None
167+
Parameters
168+
----------
169+
value : str
170+
Value to be normalized
171+
Returns
172+
-------
173+
str or None
174+
Normalized value. If the input was not a string, then None is returned
175+
"""
176+
177+
if not isinstance(value, string_types):
178+
# It is possible that user specified a dict value for one of the binary media types. We just skip them
179+
return None
180+
181+
return value.replace("~1", "/")
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""Class that provides the Api with a list of routes from a Template"""
2+
3+
import logging
4+
5+
from samcli.commands.local.lib.api_collector import ApiCollector
6+
from samcli.commands.local.lib.cfn_api_provider import CfnApiProvider
7+
from samcli.commands.local.lib.cfn_base_api_provider import CfnBaseApiProvider
8+
from samcli.commands.local.lib.provider import AbstractApiProvider
9+
from samcli.commands.local.lib.sam_api_provider import SamApiProvider
10+
from samcli.commands.local.lib.sam_base_provider import SamBaseProvider
11+
12+
LOG = logging.getLogger(__name__)
13+
14+
15+
class ApiProvider(AbstractApiProvider):
16+
17+
def __init__(self, template_dict, parameter_overrides=None, cwd=None):
18+
"""
19+
Initialize the class with template data. The template_dict is assumed
20+
to be valid, normalized and a dictionary. template_dict should be normalized by running any and all
21+
pre-processing before passing to this class.
22+
This class does not perform any syntactic validation of the template.
23+
24+
After the class is initialized, changes to ``template_dict`` will not be reflected in here.
25+
You will need to explicitly update the class with new template, if necessary.
26+
27+
Parameters
28+
----------
29+
template_dict : dict
30+
Template as a dictionary
31+
32+
cwd : str
33+
Optional working directory with respect to which we will resolve relative path to Swagger file
34+
"""
35+
self.template_dict = SamBaseProvider.get_template(template_dict, parameter_overrides)
36+
self.resources = self.template_dict.get("Resources", {})
37+
38+
LOG.debug("%d resources found in the template", len(self.resources))
39+
40+
# Store a set of apis
41+
self.cwd = cwd
42+
self.api = self._extract_api(self.resources)
43+
self.routes = self.api.routes
44+
LOG.debug("%d APIs found in the template", len(self.routes))
45+
46+
def get_all(self):
47+
"""
48+
Yields all the Apis in the current Provider
49+
50+
:yields api: an Api object with routes and properties
51+
"""
52+
53+
yield self.api
54+
55+
def _extract_api(self, resources):
56+
"""
57+
Extracts all the routes by running through the one providers. The provider that has the first type matched
58+
will be run across all the resources
59+
60+
Parameters
61+
----------
62+
resources: dict
63+
The dictionary containing the different resources within the template
64+
Returns
65+
---------
66+
An Api from the parsed template
67+
"""
68+
collector = ApiCollector()
69+
provider = self.find_api_provider(resources)
70+
provider.extract_resources(resources, collector, cwd=self.cwd)
71+
return collector.get_api()
72+
73+
@staticmethod
74+
def find_api_provider(resources):
75+
"""
76+
Finds the ApiProvider given the first api type of the resource
77+
78+
Parameters
79+
-----------
80+
resources: dict
81+
The dictionary containing the different resources within the template
82+
83+
Return
84+
----------
85+
Instance of the ApiProvider that will be run on the template with a default of SamApiProvider
86+
"""
87+
for _, resource in resources.items():
88+
if resource.get(CfnBaseApiProvider.RESOURCE_TYPE) in SamApiProvider.TYPES:
89+
return SamApiProvider()
90+
elif resource.get(CfnBaseApiProvider.RESOURCE_TYPE) in CfnApiProvider.TYPES:
91+
return CfnApiProvider()
92+
93+
return SamApiProvider()
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""Parses SAM given a template"""
2+
import logging
3+
4+
from samcli.commands.local.cli_common.user_exceptions import InvalidSamTemplateException
5+
from samcli.commands.local.lib.cfn_base_api_provider import CfnBaseApiProvider
6+
7+
LOG = logging.getLogger(__name__)
8+
9+
10+
class CfnApiProvider(CfnBaseApiProvider):
11+
APIGATEWAY_RESTAPI = "AWS::ApiGateway::RestApi"
12+
APIGATEWAY_STAGE = "AWS::ApiGateway::Stage"
13+
TYPES = [
14+
APIGATEWAY_RESTAPI,
15+
APIGATEWAY_STAGE
16+
]
17+
18+
def extract_resources(self, resources, collector, cwd=None):
19+
"""
20+
Extract the Route Object from a given resource and adds it to the RouteCollector.
21+
22+
Parameters
23+
----------
24+
resources: dict
25+
The dictionary containing the different resources within the template
26+
27+
collector: samcli.commands.local.lib.route_collector.RouteCollector
28+
Instance of the API collector that where we will save the API information
29+
30+
cwd : str
31+
Optional working directory with respect to which we will resolve relative path to Swagger file
32+
33+
Return
34+
-------
35+
Returns a list of routes
36+
"""
37+
for logical_id, resource in resources.items():
38+
resource_type = resource.get(CfnBaseApiProvider.RESOURCE_TYPE)
39+
if resource_type == CfnApiProvider.APIGATEWAY_RESTAPI:
40+
self._extract_cloud_formation_route(logical_id, resource, collector, cwd=cwd)
41+
42+
if resource_type == CfnApiProvider.APIGATEWAY_STAGE:
43+
self._extract_cloud_formation_stage(resources, resource, collector)
44+
45+
def _extract_cloud_formation_route(self, logical_id, api_resource, collector, cwd=None):
46+
"""
47+
Extract APIs from AWS::ApiGateway::RestApi resource by reading and parsing Swagger documents. The result is
48+
added to the collector.
49+
50+
Parameters
51+
----------
52+
logical_id : str
53+
Logical ID of the resource
54+
55+
api_resource : dict
56+
Resource definition, including its properties
57+
58+
collector : ApiCollector
59+
Instance of the API collector that where we will save the API information
60+
"""
61+
properties = api_resource.get("Properties", {})
62+
body = properties.get("Body")
63+
body_s3_location = properties.get("BodyS3Location")
64+
binary_media = properties.get("BinaryMediaTypes", [])
65+
66+
if not body and not body_s3_location:
67+
# Swagger is not found anywhere.
68+
LOG.debug("Skipping resource '%s'. Swagger document not found in Body and BodyS3Location",
69+
logical_id)
70+
return
71+
self.extract_swagger_route(logical_id, body, body_s3_location, binary_media, collector, cwd)
72+
73+
@staticmethod
74+
def _extract_cloud_formation_stage(resources, stage_resource, collector):
75+
"""
76+
Extract the stage from AWS::ApiGateway::Stage resource by reading and adds it to the collector.
77+
Parameters
78+
----------
79+
resources: dict
80+
All Resource definition, including its properties
81+
82+
stage_resource : dict
83+
Stage Resource definition, including its properties
84+
85+
collector : ApiCollector
86+
Instance of the API collector that where we will save the API information
87+
"""
88+
properties = stage_resource.get("Properties", {})
89+
stage_name = properties.get("StageName")
90+
stage_variables = properties.get("Variables")
91+
92+
# Currently, we aren't resolving any Refs or other intrinsic properties that come with it
93+
# A separate pr will need to fully resolve intrinsics
94+
logical_id = properties.get("RestApiId")
95+
if not logical_id:
96+
raise InvalidSamTemplateException("The AWS::ApiGateway::Stage must have a RestApiId property")
97+
98+
rest_api_resource_type = resources.get(logical_id, {}).get("Type")
99+
if rest_api_resource_type != CfnApiProvider.APIGATEWAY_RESTAPI:
100+
raise InvalidSamTemplateException(
101+
"The AWS::ApiGateway::Stage must have a valid RestApiId that points to RestApi resource {}".format(
102+
logical_id))
103+
104+
collector.stage_name = stage_name
105+
collector.stage_variables = stage_variables

0 commit comments

Comments
 (0)