Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(gatewayresponses): add support for API Gateway Responses #841

Merged
merged 9 commits into from
Mar 19, 2019
1 change: 1 addition & 0 deletions docs/globals.rst
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ Currently, the following resources and properties are being supported:
BinaryMediaTypes:
MinimumCompressionSize:
Cors:
GatewayResponses:
AccessLogSetting:
CanarySetting:
TracingEnabled:
Expand Down
10 changes: 10 additions & 0 deletions examples/2016-10-31/api_gateway_responses/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
'use strict';
const createResponse = (statusCode, body) => ({ statusCode, body });

exports.get = (event, context, callback) => {
callback(null, createResponse(200, 'You will never see this.'));
};

exports.auth = (event, context, callback) => {
return callback('Unauthorized', null)
};
48 changes: 48 additions & 0 deletions examples/2016-10-31/api_gateway_responses/template.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: Simple webservice deomnstrating gateway responses.

Resources:
ExplicitApi:
Type: AWS::Serverless::Api
Properties:
Auth:
Authorizers:
Authorizer:
FunctionArn: !GetAtt AuthorizerFunction.Arn
Identity:
ValidationExpression: "^Bearer +[-0-9a-zA-Z\\._]*$"
ReauthorizeEvery: 300
GatewayResponses:
UNAUTHORIZED:
ResponseParameters:
Headers:
Access-Control-Expose-Headers: "'WWW-Authenticate'"
Access-Control-Allow-Origin: "'*'"
WWW-Authenticate: >-
'Bearer realm="admin"'
GetFunction:
Type: AWS::Serverless::Function
Properties:
Handler: index.get
Runtime: nodejs6.10
CodeUri: src/
Events:
GetResource:
Type: Api
Properties:
Path: /resource/{resourceId}
Method: get
Auth:
Authorizer: Authorizer
RestApiId: !Ref ExplicitApi
AuthorizerFunction:
Type: AWS::Serverless::Function
Properties:
Handler: index.auth
Runtime: nodejs6.10
CodeUri: src/
Outputs:
ApiURL:
Description: "API endpoint URL for Prod environment"
Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/resource/"
54 changes: 51 additions & 3 deletions samtranslator/model/api/api_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@

from samtranslator.model.intrinsics import ref
from samtranslator.model.apigateway import (ApiGatewayDeployment, ApiGatewayRestApi,
ApiGatewayStage, ApiGatewayAuthorizer)
ApiGatewayStage, ApiGatewayAuthorizer,
ApiGatewayResponse)
from samtranslator.model.exceptions import InvalidResourceException
from samtranslator.model.s3_utils.uri_parser import parse_s3_uri
from samtranslator.region_configuration import RegionConfiguration
Expand All @@ -21,14 +22,16 @@
AuthProperties = namedtuple("_AuthProperties", ["Authorizers", "DefaultAuthorizer", "InvokeRole"])
AuthProperties.__new__.__defaults__ = (None, None, None)

GatewayResponseProperties = ["ResponseParameters", "ResponseTemplates", "StatusCode"]


class ApiGenerator(object):

def __init__(self, logical_id, cache_cluster_enabled, cache_cluster_size, variables, depends_on,
definition_body, definition_uri, name, stage_name, endpoint_configuration=None,
method_settings=None, binary_media=None, minimum_compression_size=None, cors=None,
auth=None, access_log_setting=None, canary_setting=None, tracing_enabled=None,
resource_attributes=None, passthrough_resource_attributes=None):
auth=None, gateway_responses=None, access_log_setting=None, canary_setting=None,
tracing_enabled=None, resource_attributes=None, passthrough_resource_attributes=None):
"""Constructs an API Generator class that generates API Gateway resources

:param logical_id: Logical id of the SAM API Resource
Expand Down Expand Up @@ -61,6 +64,7 @@ def __init__(self, logical_id, cache_cluster_enabled, cache_cluster_size, variab
self.minimum_compression_size = minimum_compression_size
self.cors = cors
self.auth = auth
self.gateway_responses = gateway_responses
self.access_log_setting = access_log_setting
self.canary_setting = canary_setting
self.tracing_enabled = tracing_enabled
Expand Down Expand Up @@ -91,6 +95,7 @@ def _construct_rest_api(self):

self._add_cors()
self._add_auth()
self._add_gateway_responses()

if self.definition_uri:
rest_api.BodyS3Location = self._construct_body_s3_dict()
Expand Down Expand Up @@ -275,6 +280,49 @@ def _add_auth(self):
# Assign the Swagger back to template
self.definition_body = swagger_editor.swagger

def _add_gateway_responses(self):
"""
Add Gateway Response configuration to the Swagger file, if necessary
"""

if not self.gateway_responses:
return

if self.gateway_responses and not self.definition_body:
raise InvalidResourceException(
self.logical_id, "GatewayResponses works only with inline Swagger specified in "
"'DefinitionBody' property")

# Make sure keys in the dict are recognized
for responses_key, responses_value in self.gateway_responses.items():
chrisoverzero marked this conversation as resolved.
Show resolved Hide resolved
for response_key in responses_value.keys():
if response_key not in GatewayResponseProperties:
raise InvalidResourceException(
self.logical_id,
"Invalid property '{}' in 'GatewayResponses' property '{}'".format(response_key, responses_key))

if not SwaggerEditor.is_valid(self.definition_body):
raise InvalidResourceException(
self.logical_id, "Unable to add Auth configuration because "
"'DefinitionBody' does not contain a valid Swagger")

swagger_editor = SwaggerEditor(self.definition_body)

gateway_responses = {}
for response_type, response in self.gateway_responses.items():
gateway_responses[response_type] = ApiGatewayResponse(
api_logical_id=self.logical_id,
response_parameters=response.get('ResponseParameters', {}),
response_templates=response.get('ResponseTemplates', {}),
status_code=response.get('StatusCode', None)
)

if gateway_responses:
swagger_editor.add_gateway_responses(gateway_responses)

# Assign the Swagger back to template
self.definition_body = swagger_editor.swagger

def _get_authorizers(self, authorizers_config, default_authorizer=None):
authorizers = {}
if default_authorizer == 'AWS_IAM':
Expand Down
47 changes: 47 additions & 0 deletions samtranslator/model/apigateway.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from re import match

from samtranslator.model import PropertyType, Resource
from samtranslator.model.exceptions import InvalidResourceException
from samtranslator.model.types import is_type, one_of, is_str
Expand Down Expand Up @@ -93,6 +95,51 @@ def make_auto_deployable(self, stage, swagger=None):
stage.update_deployment_ref(self.logical_id)


class ApiGatewayResponse(object):
ResponseParameterProperties = ["Headers", "Paths", "QueryStrings"]

def __init__(self, api_logical_id=None, response_parameters=None, response_templates=None, status_code=None):
if response_parameters:
for response_parameter_key in response_parameters.keys():
if response_parameter_key not in ApiGatewayResponse.ResponseParameterProperties:
raise InvalidResourceException(
api_logical_id,
"Invalid gateway response parameter '{}'".format(response_parameter_key))

# status_code must look like a status code, if present. Let's not be judgmental; just check 0-999.
if status_code and not match(r'^[0-9]{1,3}$', str(status_code)):
chrisoverzero marked this conversation as resolved.
Show resolved Hide resolved
raise InvalidResourceException(api_logical_id, "Property 'StatusCode' must be numeric")

self.api_logical_id = api_logical_id
self.response_parameters = response_parameters or {}
self.response_templates = response_templates or {}
self.status_code = status_code

def generate_swagger(self):
swagger = {
"responseParameters": self._add_prefixes(self.response_parameters),
"responseTemplates": self.response_templates
}

# Prevent "null" being written.
if self.status_code:
chrisoverzero marked this conversation as resolved.
Show resolved Hide resolved
swagger["statusCode"] = self.status_code

return swagger

def _add_prefixes(self, response_parameters):
GATEWAY_RESPONSE_PREFIX = 'gatewayresponse.'
prefixed_parameters = {}
for key, value in response_parameters.get('Headers', {}).items():
prefixed_parameters[GATEWAY_RESPONSE_PREFIX + 'header.' + key] = value
for key, value in response_parameters.get('Paths', {}).items():
prefixed_parameters[GATEWAY_RESPONSE_PREFIX + 'path.' + key] = value
for key, value in response_parameters.get('QueryStrings', {}).items():
prefixed_parameters[GATEWAY_RESPONSE_PREFIX + 'querystring.' + key] = value

return prefixed_parameters


class ApiGatewayAuthorizer(object):
_VALID_FUNCTION_PAYLOAD_TYPES = [None, 'TOKEN', 'REQUEST']

Expand Down
4 changes: 3 additions & 1 deletion samtranslator/model/sam_resources.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
""" SAM macro definitions """
""" SAM macro definitions """
from six import string_types

import samtranslator.model.eventsources
Expand Down Expand Up @@ -442,6 +442,7 @@ class SamApi(SamResourceMacro):
'MinimumCompressionSize': PropertyType(False, is_type(int)),
'Cors': PropertyType(False, one_of(is_str(), is_type(dict))),
'Auth': PropertyType(False, is_type(dict)),
'GatewayResponses': PropertyType(False, is_type(dict)),
'AccessLogSetting': PropertyType(False, is_type(dict)),
'CanarySetting': PropertyType(False, is_type(dict)),
'TracingEnabled': PropertyType(False, is_type(bool))
Expand Down Expand Up @@ -477,6 +478,7 @@ def to_cloudformation(self, **kwargs):
minimum_compression_size=self.MinimumCompressionSize,
cors=self.Cors,
auth=self.Auth,
gateway_responses=self.GatewayResponses,
access_log_setting=self.AccessLogSetting,
canary_setting=self.CanarySetting,
tracing_enabled=self.TracingEnabled,
Expand Down
3 changes: 2 additions & 1 deletion samtranslator/plugins/globals/globals.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from samtranslator.public.sdk.resource import SamResourceType
from samtranslator.public.sdk.resource import SamResourceType
from samtranslator.public.intrinsics import is_intrinsics


Expand Down Expand Up @@ -49,6 +49,7 @@ class Globals(object):
"BinaryMediaTypes",
"MinimumCompressionSize",
"Cors",
"GatewayResponses",
"AccessLogSetting",
"CanarySetting",
"TracingEnabled"
Expand Down
23 changes: 19 additions & 4 deletions samtranslator/swagger/swagger.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import copy
import copy
from six import string_types

from samtranslator.model.intrinsics import ref
Expand All @@ -16,6 +16,7 @@ class SwaggerEditor(object):
_OPTIONS_METHOD = "options"
_X_APIGW_INTEGRATION = 'x-amazon-apigateway-integration'
_CONDITIONAL_IF = "Fn::If"
_X_APIGW_GATEWAY_RESPONSES = 'x-amazon-apigateway-gateway-responses'
_X_ANY_METHOD = 'x-amazon-apigateway-any-method'

def __init__(self, doc):
Expand All @@ -33,6 +34,7 @@ def __init__(self, doc):
self._doc = copy.deepcopy(doc)
self.paths = self._doc["paths"]
self.security_definitions = self._doc.get("securityDefinitions", {})
self.gateway_responses = self._doc.get(self._X_APIGW_GATEWAY_RESPONSES, {})

def get_path(self, path):
path_dict = self.paths.get(path)
Expand Down Expand Up @@ -386,8 +388,8 @@ def add_authorizers(self, authorizers):
"""
self.security_definitions = self.security_definitions or {}

for authorizerName, authorizer in authorizers.items():
self.security_definitions[authorizerName] = authorizer.generate_swagger()
for authorizer_name, authorizer in authorizers.items():
self.security_definitions[authorizer_name] = authorizer.generate_swagger()

def set_path_default_authorizer(self, path, default_authorizer, authorizers):
"""
Expand Down Expand Up @@ -491,7 +493,7 @@ def set_method_authorizer(self, path, method_name, authorizer_name, authorizers,

if security:
method_definition['security'] = security

# The first element of the method_definition['security'] should be AWS_IAM
# because authorizer_list = ['AWS_IAM'] is hardcoded above
if 'AWS_IAM' in method_definition['security'][0]:
Expand All @@ -508,6 +510,17 @@ def set_method_authorizer(self, path, method_name, authorizer_name, authorizers,
elif 'AWS_IAM' not in self.security_definitions:
self.security_definitions.update(aws_iam_security_definition)

def add_gateway_responses(self, gateway_responses):
"""
Add Gateway Response definitions to Swagger.

:param dict gateway_responses: Dictionary of GatewayResponse configuration which gets translated.
"""
self.gateway_responses = self.gateway_responses or {}

for response_type, response in gateway_responses.items():
self.gateway_responses[response_type] = response.generate_swagger()

@property
def swagger(self):
"""
Expand All @@ -521,6 +534,8 @@ def swagger(self):

if self.security_definitions:
self._doc["securityDefinitions"] = self.security_definitions
if self.gateway_responses:
self._doc[self._X_APIGW_GATEWAY_RESPONSES] = self.gateway_responses

return copy.deepcopy(self._doc)

Expand Down
28 changes: 28 additions & 0 deletions tests/translator/input/api_with_gateway_responses.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
Resources:
Function:
Type: AWS::Serverless::Function
Properties:
CodeUri: s3://sam-demo-bucket/member_portal.zip
Handler: index.gethtml
Runtime: nodejs4.3
Events:
GetHtml:
Type: Api
Properties:
Path: /
Method: get
RestApiId: !Ref ExplicitApi

ExplicitApi:
Type: AWS::Serverless::Api
Properties:
StageName: Prod
GatewayResponses:
UNAUTHORIZED:
StatusCode: 401
ResponseParameters:
Headers:
Access-Control-Expose-Headers: "'WWW-Authenticate'"
Access-Control-Allow-Origin: "'*'"
WWW-Authenticate: >-
'Bearer realm="admin"'
32 changes: 32 additions & 0 deletions tests/translator/input/api_with_gateway_responses_all.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
Resources:
Function:
Type: AWS::Serverless::Function
Properties:
CodeUri: s3://sam-demo-bucket/member_portal.zip
Handler: index.gethtml
Runtime: nodejs4.3
Events:
GetHtml:
Type: Api
Properties:
Path: /
Method: get
RestApiId: !Ref ExplicitApi

ExplicitApi:
Type: AWS::Serverless::Api
Properties:
StageName: Prod
GatewayResponses:
UNAUTHORIZED:
StatusCode: 401
ResponseParameters:
Headers:
Access-Control-Expose-Headers: "'WWW-Authenticate'"
Access-Control-Allow-Origin: "'*'"
WWW-Authenticate: >-
'Bearer realm="admin"'
Paths:
PathKey: "'path-value'"
QueryStrings:
QueryStringKey: "'query-string-value'"
Loading