From 56e696f79fe022bf3aca964960d178474bd413a5 Mon Sep 17 00:00:00 2001 From: James Saryerwinnie Date: Wed, 21 Sep 2016 17:29:16 -0700 Subject: [PATCH 01/10] Add option to enable cors per route This has the default set of values that mirror what you get in the console. It does not yet set up an ``OPTIONS`` method for you. --- chalice/app.py | 18 +++----- chalice/app.pyi | 3 +- chalice/deployer.py | 90 ++++++++++++++++++++----------------- tests/unit/test_deployer.py | 26 +++++------ 4 files changed, 71 insertions(+), 66 deletions(-) diff --git a/chalice/app.py b/chalice/app.py index 54be8337f..d373ac836 100644 --- a/chalice/app.py +++ b/chalice/app.py @@ -98,7 +98,8 @@ class RouteEntry(object): def __init__(self, view_function, view_name, path, methods, authorization_type=None, authorizer_id=None, - api_key_required=None, content_types=None): + api_key_required=None, content_types=None, + cors=False): self.view_function = view_function self.view_name = view_name self.uri_pattern = path @@ -110,6 +111,7 @@ def __init__(self, view_function, view_name, path, methods, #: e.g, '/foo/{bar}/{baz}/qux -> ['bar', 'baz'] self.view_args = self._parse_view_args() self.content_types = content_types + self.cors = cors def _parse_view_args(self): if '{' not in self.uri_pattern: @@ -144,6 +146,7 @@ def _add_route(self, path, view_func, **kwargs): authorizer_id = kwargs.get('authorizer_id', None) api_key_required = kwargs.get('api_key_required', None) content_types = kwargs.get('content_types', ['application/json']) + cors = kwargs.get('cors', False) if not isinstance(content_types, list): raise ValueError('In view function "%s", the content_types ' 'value must be a list, not %s: %s' @@ -153,16 +156,9 @@ def _add_route(self, path, view_func, **kwargs): raise ValueError( "Duplicate route detected: '%s'\n" "URL paths must be unique." % path) - self.routes[path] = RouteEntry( - view_func, - name, - path, - methods, - authorization_type, - authorizer_id, - api_key_required, - content_types, - ) + entry = RouteEntry(view_func, name, path, methods, authorization_type, + authorizer_id, api_key_required, content_types, cors) + self.routes[path] = entry def __call__(self, event, context): # This is what's invoked via lambda. diff --git a/chalice/app.pyi b/chalice/app.pyi index 992af97e5..b83d15e7b 100644 --- a/chalice/app.pyi +++ b/chalice/app.pyi @@ -49,7 +49,8 @@ class RouteEntry(object): authorization_type: str=None, authorizer_id: str=None, api_key_required: bool=None, - content_types: List[str]=None) -> None: ... + content_types: List[str]=None, + cors: bool) -> None: ... def _parse_view_args(self) -> List[str]: ... diff --git a/chalice/deployer.py b/chalice/deployer.py index e9fe37690..93e8a9c3a 100644 --- a/chalice/deployer.py +++ b/chalice/deployer.py @@ -25,7 +25,6 @@ from chalice.awsclient import TypedAWSClient from chalice import compat - LAMBDA_TRUST_POLICY = { "Version": "2012-10-17", "Statement": [{ @@ -38,7 +37,6 @@ ] } - CLOUDWATCH_LOGS = { "Effect": "Allow", "Action": [ @@ -49,7 +47,6 @@ "Resource": "arn:aws:logs:*:*:*" } - FULL_PASSTHROUGH = """ #set($allParams = $input.params()) { @@ -97,7 +94,6 @@ } """ - ERROR_MAPPING = ( "#set($inputRoot = $input.path('$'))" "{" @@ -219,14 +215,12 @@ def node(name, uri_path, is_route=False): class NoPrompt(object): - def confirm(self, text, default=False, abort=False): # type: (str, bool, bool) -> bool return default class Deployer(object): - def __init__(self, apigateway_deploy, lambda_deploy): # type: (APIGatewayDeployer, LambdaDeployer) -> None self._apigateway_deploy = apigateway_deploy @@ -250,7 +244,7 @@ def deploy(self, config): config) print ( "https://{api_id}.execute-api.{region}.amazonaws.com/{stage}/" - .format(api_id=rest_api_id, region=region_name, stage=stage) + .format(api_id=rest_api_id, region=region_name, stage=stage) ) return rest_api_id, region_name, stage @@ -354,41 +348,58 @@ def _configure_resource_route(self, node, http_method): passthroughBehavior="NEVER", requestTemplates={ key: FULL_PASSTHROUGH for key in content_types - }, + }, uri=self._lambda_uri() ) # Success case. - c.put_integration_response( - restApiId=self.rest_api_id, - resourceId=node['resource_id'], - httpMethod=http_method, - statusCode='200', - responseTemplates={'application/json': ''}, - ) - c.put_method_response( - restApiId=self.rest_api_id, - resourceId=node['resource_id'], - httpMethod=http_method, - statusCode='200', - responseModels={'application/json': 'Empty'}, - ) + integration_response_args = { + 'restApiId': self.rest_api_id, + 'resourceId': node['resource_id'], + 'httpMethod': http_method, + 'statusCode': '200', + 'responseTemplates': {'application/json': ''}, + } + method_response_args = { + 'restApiId': self.rest_api_id, + 'resourceId': node['resource_id'], + 'httpMethod': http_method, + 'statusCode': '200', + 'responseModels': {'application/json': 'Empty'}, + } + if route_entry.cors: + method_response_args['responseParameters'] = { + 'method.response.header.Access-Control-Allow-Origin': False} + c.put_method_response(**method_response_args) + if route_entry.cors: + integration_response_args['responseParameters'] = { + 'method.response.header.Access-Control-Allow-Origin': "'*'"} + c.put_integration_response(**integration_response_args) + # And we have to create a pair for each error type. for error_cls in app.ALL_ERRORS: - c.put_integration_response( - restApiId=self.rest_api_id, - resourceId=node['resource_id'], - httpMethod=http_method, - statusCode=str(error_cls.STATUS_CODE), - selectionPattern=error_cls.__name__ + '.*', - responseTemplates={'application/json': ERROR_MAPPING}, - ) - c.put_method_response( - restApiId=self.rest_api_id, - resourceId=node['resource_id'], - httpMethod=http_method, - statusCode=str(error_cls.STATUS_CODE), - responseModels={'application/json': 'Empty'}, - ) + method_response_args = { + 'restApiId': self.rest_api_id, + 'resourceId': node['resource_id'], + 'httpMethod': http_method, + 'statusCode': str(error_cls.STATUS_CODE), + 'responseModels': {'application/json': 'Empty'}, + } + if route_entry.cors: + method_response_args['responseParameters'] = { + 'method.response.header.Access-Control-Allow-Origin': False} + c.put_method_response(**method_response_args) + integration_response_args = { + 'restApiId': self.rest_api_id, + 'resourceId': node['resource_id'], + 'httpMethod': http_method, + 'statusCode': str(error_cls.STATUS_CODE), + 'selectionPattern': error_cls.__name__ + '.*', + 'responseTemplates': {'application/json': ERROR_MAPPING}, + } + if route_entry.cors: + integration_response_args['responseParameters'] = { + 'method.response.header.Access-Control-Allow-Origin': "'*'"} + c.put_integration_response(**integration_response_args) def _lambda_uri(self): # type: () -> str @@ -404,7 +415,6 @@ def _lambda_uri(self): class LambdaDeploymentPackager(object): - def _create_virtualenv(self, venv_dir): # type: (str) -> None # The original implementation used Popen(['virtualenv', ...]) @@ -505,7 +515,7 @@ def _add_app_files(self, zip, project_dir): chalice_init = inspect.getfile(chalice) if chalice_init.endswith('.pyc'): chalice_init = chalice_init[:-1] - zip.write(chalice_router, 'chalice/__init__.py') + zip.write(chalice_init, 'chalice/__init__.py') zip.write(os.path.join(project_dir, 'app.py'), 'app.py') @@ -559,7 +569,6 @@ def inject_latest_app(self, deployment_package_filename, project_dir): class LambdaDeployer(object): - def __init__(self, aws_client, # type: TypedAWSClient packager, # type: LambdaDeploymentPackager @@ -718,7 +727,6 @@ def _create_role_from_source_code(self, config): class APIGatewayDeployer(object): - def __init__(self, aws_client, # type: TypedAWSClient api_gateway_client, # type: Any diff --git a/tests/unit/test_deployer.py b/tests/unit/test_deployer.py index 65e426d4a..9fcbab294 100644 --- a/tests/unit/test_deployer.py +++ b/tests/unit/test_deployer.py @@ -149,27 +149,27 @@ def test_validation_error_if_no_role_provided_when_manage_false(sample_app): def add_expected_calls_to_map_error(error_cls, gateway_stub): gateway_stub.add_response( - 'put_integration_response', + 'put_method_response', service_response={}, expected_params={ 'httpMethod': 'POST', 'resourceId': 'parent-id', - 'responseTemplates': {'application/json': ERROR_MAPPING}, + 'responseModels': { + 'application/json': 'Empty', + }, 'restApiId': 'rest-api-id', - 'selectionPattern': '%s.*' % error_cls.__name__, 'statusCode': str(error_cls.STATUS_CODE), } ) gateway_stub.add_response( - 'put_method_response', + 'put_integration_response', service_response={}, expected_params={ 'httpMethod': 'POST', 'resourceId': 'parent-id', - 'responseModels': { - 'application/json': 'Empty', - }, + 'responseTemplates': {'application/json': ERROR_MAPPING}, 'restApiId': 'rest-api-id', + 'selectionPattern': '%s.*' % error_cls.__name__, 'statusCode': str(error_cls.STATUS_CODE), } ) @@ -222,26 +222,26 @@ def test_can_build_resource_routes_for_single_view(stubbed_api_gateway, stubbed_ } ) gateway_stub.add_response( - 'put_integration_response', + 'put_method_response', service_response={}, expected_params={ 'httpMethod': 'POST', 'resourceId': 'parent-id', - 'responseTemplates': { - 'application/json': '', + 'responseModels': { + 'application/json': 'Empty', }, 'restApiId': 'rest-api-id', 'statusCode': '200', } ) gateway_stub.add_response( - 'put_method_response', + 'put_integration_response', service_response={}, expected_params={ 'httpMethod': 'POST', 'resourceId': 'parent-id', - 'responseModels': { - 'application/json': 'Empty', + 'responseTemplates': { + 'application/json': '', }, 'restApiId': 'rest-api-id', 'statusCode': '200', From 74480278976f6a38d1a27faf7c3d940134392b84 Mon Sep 17 00:00:00 2001 From: James Saryerwinnie Date: Wed, 28 Sep 2016 21:11:28 -0700 Subject: [PATCH 02/10] Add OPTIONS request when cors is enabled --- chalice/cli/__init__.py | 1 + chalice/deployer.py | 49 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/chalice/cli/__init__.py b/chalice/cli/__init__.py index 13474ae68..44dd8bf04 100644 --- a/chalice/cli/__init__.py +++ b/chalice/cli/__init__.py @@ -148,6 +148,7 @@ def deploy(ctx, project_dir, autogen_policy, profile, stage): e.exit_code = 2 raise e except Exception as e: + raise e = click.ClickException("Error when deploying: %s" % e) e.exit_code = 1 raise e diff --git a/chalice/deployer.py b/chalice/deployer.py index 93e8a9c3a..d69f5b864 100644 --- a/chalice/deployer.py +++ b/chalice/deployer.py @@ -293,6 +293,8 @@ def build_resources(self, chalice_trie): assert current['route_entry'] is not None, current for http_method in current['route_entry'].methods: self._configure_resource_route(current, http_method) + if current['route_entry'].cors: + self._add_options_preflight_request(current) for child in current['children']: stack.append(current['children'][child]) # Add a catch all auth that says anything in this rest API can call @@ -401,6 +403,53 @@ def _configure_resource_route(self, node, http_method): 'method.response.header.Access-Control-Allow-Origin': "'*'"} c.put_integration_response(**integration_response_args) + def _add_options_preflight_request(self, node): + # If CORs is configured we also need to set up + # an OPTIONS method for them for preflight requests. + # TODO: We should probably warn/error if they've also configured + # the view function to support an OPTIONs method. + c = self.client + c.put_method( + restApiId=self.rest_api_id, + resourceId=node['resource_id'], + httpMethod='OPTIONS', + authorizationType='NONE', + ) + c.put_integration( + restApiId=self.rest_api_id, + resourceId=node['resource_id'], + httpMethod='OPTIONS', + type='MOCK', + requestTemplates={ + 'application/json': '{"statusCode": 200}', + }, + ) + c.put_method_response( + restApiId=self.rest_api_id, + resourceId=node['resource_id'], + httpMethod='OPTIONS', + statusCode='200', + responseModels={'application/json': 'Empty'}, + responseParameters={ + "method.response.header.Access-Control-Allow-Origin": False, + "method.response.header.Access-Control-Allow-Methods": False, + "method.response.header.Access-Control-Allow-Headers": False, + }, + ) + c.put_integration_response( + restApiId=self.rest_api_id, + resourceId=node['resource_id'], + httpMethod='OPTIONS', + statusCode='200', + responseTemplates={'application/json': ''}, + responseParameters={ + "method.response.header.Access-Control-Allow-Origin": "'*'", + # TODO: This should be all the allowed methods in their view. + "method.response.header.Access-Control-Allow-Methods": "'POST,GET,PUT,OPTIONS'", + "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'" + }, + ) + def _lambda_uri(self): # type: () -> str region_name = self.client.meta.region_name From 0c1a273e04ef79813d5e24b589d70b1bca511ae3 Mon Sep 17 00:00:00 2001 From: James Saryerwinnie Date: Thu, 29 Sep 2016 10:49:39 -0700 Subject: [PATCH 03/10] Add smoke test for CORS support --- tests/integration/test_features.py | 17 ++++++++++++++++- tests/integration/testapp/app.py | 7 +++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/tests/integration/test_features.py b/tests/integration/test_features.py index 0c77ff7c3..7a2b71354 100644 --- a/tests/integration/test_features.py +++ b/tests/integration/test_features.py @@ -127,7 +127,7 @@ def test_can_raise_bad_request(smoke_test_app): assert response.json()['Message'] == 'Bad request.' -def test_can_raise_bad_request(smoke_test_app): +def test_can_raise_not_found(smoke_test_app): response = requests.get(smoke_test_app.url + '/notfound') assert response.status_code == 404 assert response.json()['Code'] == 'NotFoundError' @@ -155,3 +155,18 @@ def test_form_encoded_content_type(smoke_test_app): data={'foo': 'bar'}) response.raise_for_status() assert response.json() == {'parsed': {'foo': ['bar']}} + + +def test_can_support_cors(smoke_test_app): + response = requests.get(smoke_test_app.url + '/cors') + response.raise_for_status() + assert response.headers['Access-Control-Allow-Origin'] == '*' + + # Should also have injected an OPTIONs request. + response = requests.options(smoke_test_app.url + '/cors') + response.raise_for_status() + headers = response.headers + assert headers['Access-Control-Allow-Origin'] == '*' + assert headers['Access-Control-Allow-Headers'] == ( + 'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token') + assert headers['Access-Control-Allow-Methods'] == 'POST,GET,PUT,OPTIONS' diff --git a/tests/integration/testapp/app.py b/tests/integration/testapp/app.py index 9c91abf31..4ce3617bb 100644 --- a/tests/integration/testapp/app.py +++ b/tests/integration/testapp/app.py @@ -66,3 +66,10 @@ def form_encoded(): return { 'parsed': parsed } + + +@app.route('/cors', methods=['GET', 'POST', 'PUT'], cors=True) +def supports_cors(): + # It doesn't really matter what we return here because + # we'll be checking the response headers to verify CORS support. + return {'cors': True} From 331456fefc37371e2bf8ee6673a4147bd2f1555c Mon Sep 17 00:00:00 2001 From: James Saryerwinnie Date: Thu, 29 Sep 2016 10:58:14 -0700 Subject: [PATCH 04/10] Clean up lint issues --- chalice/app.py | 3 ++- chalice/app.pyi | 2 +- chalice/deployer.py | 20 +++++++++++++------- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/chalice/app.py b/chalice/app.py index d373ac836..889445d16 100644 --- a/chalice/app.py +++ b/chalice/app.py @@ -157,7 +157,8 @@ def _add_route(self, path, view_func, **kwargs): "Duplicate route detected: '%s'\n" "URL paths must be unique." % path) entry = RouteEntry(view_func, name, path, methods, authorization_type, - authorizer_id, api_key_required, content_types, cors) + authorizer_id, api_key_required, + content_types, cors) self.routes[path] = entry def __call__(self, event, context): diff --git a/chalice/app.pyi b/chalice/app.pyi index b83d15e7b..955ebed22 100644 --- a/chalice/app.pyi +++ b/chalice/app.pyi @@ -50,7 +50,7 @@ class RouteEntry(object): authorizer_id: str=None, api_key_required: bool=None, content_types: List[str]=None, - cors: bool) -> None: ... + cors: bool=False) -> None: ... def _parse_view_args(self) -> List[str]: ... diff --git a/chalice/deployer.py b/chalice/deployer.py index d69f5b864..eab27d318 100644 --- a/chalice/deployer.py +++ b/chalice/deployer.py @@ -244,7 +244,7 @@ def deploy(self, config): config) print ( "https://{api_id}.execute-api.{region}.amazonaws.com/{stage}/" - .format(api_id=rest_api_id, region=region_name, stage=stage) + .format(api_id=rest_api_id, region=region_name, stage=stage) ) return rest_api_id, region_name, stage @@ -350,7 +350,7 @@ def _configure_resource_route(self, node, http_method): passthroughBehavior="NEVER", requestTemplates={ key: FULL_PASSTHROUGH for key in content_types - }, + }, uri=self._lambda_uri() ) # Success case. @@ -388,7 +388,8 @@ def _configure_resource_route(self, node, http_method): } if route_entry.cors: method_response_args['responseParameters'] = { - 'method.response.header.Access-Control-Allow-Origin': False} + 'method.response.header.Access-Control-Allow-Origin': + False} c.put_method_response(**method_response_args) integration_response_args = { 'restApiId': self.rest_api_id, @@ -400,10 +401,12 @@ def _configure_resource_route(self, node, http_method): } if route_entry.cors: integration_response_args['responseParameters'] = { - 'method.response.header.Access-Control-Allow-Origin': "'*'"} + 'method.response.header.Access-Control-Allow-Origin': + "'*'"} c.put_integration_response(**integration_response_args) def _add_options_preflight_request(self, node): + # type: (Dict[str, Any]) -> None # If CORs is configured we also need to set up # an OPTIONS method for them for preflight requests. # TODO: We should probably warn/error if they've also configured @@ -430,7 +433,7 @@ def _add_options_preflight_request(self, node): httpMethod='OPTIONS', statusCode='200', responseModels={'application/json': 'Empty'}, - responseParameters={ + responseParameters={ "method.response.header.Access-Control-Allow-Origin": False, "method.response.header.Access-Control-Allow-Methods": False, "method.response.header.Access-Control-Allow-Headers": False, @@ -445,8 +448,11 @@ def _add_options_preflight_request(self, node): responseParameters={ "method.response.header.Access-Control-Allow-Origin": "'*'", # TODO: This should be all the allowed methods in their view. - "method.response.header.Access-Control-Allow-Methods": "'POST,GET,PUT,OPTIONS'", - "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'" + "method.response.header.Access-Control-Allow-Methods": ( + "'POST,GET,PUT,OPTIONS'"), + "method.response.header.Access-Control-Allow-Headers": ( + "'Content-Type,X-Amz-Date,Authorization,X-Api-Key" + ",X-Amz-Security-Token'") }, ) From 9ed75c1353e648ed6ab45489ca0990e995cd22ef Mon Sep 17 00:00:00 2001 From: James Saryerwinnie Date: Fri, 7 Oct 2016 16:15:53 -0700 Subject: [PATCH 05/10] Extract error methods into separate method --- chalice/app.pyi | 8 +++++++- chalice/deployer.py | 8 +++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/chalice/app.pyi b/chalice/app.pyi index 955ebed22..f316cec9c 100644 --- a/chalice/app.pyi +++ b/chalice/app.pyi @@ -41,9 +41,15 @@ class RouteEntry(object): # TODO: How so I specify *args, where args is a tuple of strings. view_function = ... # type: Callable[..., Any] view_name = ... # type: str - uri_pattern = ... # type: str methods = ... # type: List[str] + uri_pattern = ... # type: str + authorization_type = ... # type: str + authorizer_id = ... # type: str + api_key_required = ... # type: bool + content_types = ... # type: List[str] view_args = ... # type: List[str] + cors = ... # type: bool + def __init__(self, view_function: Callable[..., Any], view_name: str, path: str, methods: List[str], authorization_type: str=None, diff --git a/chalice/deployer.py b/chalice/deployer.py index eab27d318..5e2d0cdeb 100644 --- a/chalice/deployer.py +++ b/chalice/deployer.py @@ -376,8 +376,10 @@ def _configure_resource_route(self, node, http_method): integration_response_args['responseParameters'] = { 'method.response.header.Access-Control-Allow-Origin': "'*'"} c.put_integration_response(**integration_response_args) + self._add_error_responses(http_method, node, route_entry, c) - # And we have to create a pair for each error type. + def _add_error_responses(self, http_method, node, route_entry, client): + # type: (str, Dict[str, Any], app.RouteEntry, Any) -> None for error_cls in app.ALL_ERRORS: method_response_args = { 'restApiId': self.rest_api_id, @@ -390,7 +392,7 @@ def _configure_resource_route(self, node, http_method): method_response_args['responseParameters'] = { 'method.response.header.Access-Control-Allow-Origin': False} - c.put_method_response(**method_response_args) + client.put_method_response(**method_response_args) integration_response_args = { 'restApiId': self.rest_api_id, 'resourceId': node['resource_id'], @@ -403,7 +405,7 @@ def _configure_resource_route(self, node, http_method): integration_response_args['responseParameters'] = { 'method.response.header.Access-Control-Allow-Origin': "'*'"} - c.put_integration_response(**integration_response_args) + client.put_integration_response(**integration_response_args) def _add_options_preflight_request(self, node): # type: (Dict[str, Any]) -> None From e2016d156553b02f2507dc4a05d9c39bc129ea46 Mon Sep 17 00:00:00 2001 From: James Saryerwinnie Date: Sun, 9 Oct 2016 21:15:38 -0700 Subject: [PATCH 06/10] Use methods from views --- chalice/deployer.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/chalice/deployer.py b/chalice/deployer.py index 5e2d0cdeb..82988132e 100644 --- a/chalice/deployer.py +++ b/chalice/deployer.py @@ -294,7 +294,8 @@ def build_resources(self, chalice_trie): for http_method in current['route_entry'].methods: self._configure_resource_route(current, http_method) if current['route_entry'].cors: - self._add_options_preflight_request(current) + self._add_options_preflight_request( + current, current['route_entry'].methods) for child in current['children']: stack.append(current['children'][child]) # Add a catch all auth that says anything in this rest API can call @@ -407,7 +408,7 @@ def _add_error_responses(self, http_method, node, route_entry, client): "'*'"} client.put_integration_response(**integration_response_args) - def _add_options_preflight_request(self, node): + def _add_options_preflight_request(self, node, http_methods): # type: (Dict[str, Any]) -> None # If CORs is configured we also need to set up # an OPTIONS method for them for preflight requests. @@ -441,6 +442,9 @@ def _add_options_preflight_request(self, node): "method.response.header.Access-Control-Allow-Headers": False, }, ) + if 'OPTIONS' not in http_methods: + http_methods.append('OPTIONS') + allowed_methods = ','.join(http_methods) c.put_integration_response( restApiId=self.rest_api_id, resourceId=node['resource_id'], @@ -451,7 +455,7 @@ def _add_options_preflight_request(self, node): "method.response.header.Access-Control-Allow-Origin": "'*'", # TODO: This should be all the allowed methods in their view. "method.response.header.Access-Control-Allow-Methods": ( - "'POST,GET,PUT,OPTIONS'"), + "'%s'" % allowed_methods), "method.response.header.Access-Control-Allow-Headers": ( "'Content-Type,X-Amz-Date,Authorization,X-Api-Key" ",X-Amz-Security-Token'") From a3dd713970aefe889294370f4dd99234e4be072b Mon Sep 17 00:00:00 2001 From: James Saryerwinnie Date: Sun, 9 Oct 2016 21:28:19 -0700 Subject: [PATCH 07/10] Bump pytest cov version to remove warnings --- requirements-dev.txt | 2 +- requirements-test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index c9b2f2b6f..e66af7fc0 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,6 +4,6 @@ tox==2.2.1 wheel==0.26.0 doc8==0.7.0 pylint==1.5.5 -pytest-cov==2.3.0 +pytest-cov==2.3.1 pydocstyle==1.0.0 -rrequirements-test.txt diff --git a/requirements-test.txt b/requirements-test.txt index dc1df8299..32908591e 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,4 +1,4 @@ -pytest==3.0.2 +pytest==3.0.3 py==1.4.31 pygments==2.1.3 mock==2.0.0 From 8b9e862b29b8f42dfbce0e7c4f669cedf393e035 Mon Sep 17 00:00:00 2001 From: James Saryerwinnie Date: Sun, 9 Oct 2016 21:31:12 -0700 Subject: [PATCH 08/10] Fix type signature --- chalice/deployer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chalice/deployer.py b/chalice/deployer.py index 82988132e..49ee66072 100644 --- a/chalice/deployer.py +++ b/chalice/deployer.py @@ -409,7 +409,7 @@ def _add_error_responses(self, http_method, node, route_entry, client): client.put_integration_response(**integration_response_args) def _add_options_preflight_request(self, node, http_methods): - # type: (Dict[str, Any]) -> None + # type: (Dict[str, Any], List[str]) -> None # If CORs is configured we also need to set up # an OPTIONS method for them for preflight requests. # TODO: We should probably warn/error if they've also configured From 26d1705cc39f51ed2a40963c035c4074acbe9847 Mon Sep 17 00:00:00 2001 From: James Saryerwinnie Date: Mon, 10 Oct 2016 09:33:12 -0700 Subject: [PATCH 09/10] Add test to verify CORS mappings It's probably worth refactoring this logic into a separate class, especially when support is added to configure exactly what CORS settings you want. --- chalice/deployer.py | 1 - tests/unit/test_deployer.py | 184 +++++++++++++++++++++++++++++++++++- 2 files changed, 179 insertions(+), 6 deletions(-) diff --git a/chalice/deployer.py b/chalice/deployer.py index 49ee66072..578aa3fc2 100644 --- a/chalice/deployer.py +++ b/chalice/deployer.py @@ -453,7 +453,6 @@ def _add_options_preflight_request(self, node, http_methods): responseTemplates={'application/json': ''}, responseParameters={ "method.response.header.Access-Control-Allow-Origin": "'*'", - # TODO: This should be all the allowed methods in their view. "method.response.header.Access-Control-Allow-Methods": ( "'%s'" % allowed_methods), "method.response.header.Access-Control-Allow-Headers": ( diff --git a/tests/unit/test_deployer.py b/tests/unit/test_deployer.py index 9fcbab294..11de0ab20 100644 --- a/tests/unit/test_deployer.py +++ b/tests/unit/test_deployer.py @@ -16,7 +16,7 @@ from chalice.app import Chalice from chalice.config import Config -from botocore.stub import Stubber +from botocore.stub import Stubber, ANY import botocore.session @@ -147,12 +147,12 @@ def test_validation_error_if_no_role_provided_when_manage_false(sample_app): validate_configuration(config) -def add_expected_calls_to_map_error(error_cls, gateway_stub): +def add_expected_calls_to_map_error(error_cls, gateway_stub, http_method='POST'): gateway_stub.add_response( 'put_method_response', service_response={}, expected_params={ - 'httpMethod': 'POST', + 'httpMethod': http_method, 'resourceId': 'parent-id', 'responseModels': { 'application/json': 'Empty', @@ -165,7 +165,7 @@ def add_expected_calls_to_map_error(error_cls, gateway_stub): 'put_integration_response', service_response={}, expected_params={ - 'httpMethod': 'POST', + 'httpMethod': http_method, 'resourceId': 'parent-id', 'responseTemplates': {'application/json': ERROR_MAPPING}, 'restApiId': 'rest-api-id', @@ -175,7 +175,8 @@ def add_expected_calls_to_map_error(error_cls, gateway_stub): ) -def test_can_build_resource_routes_for_single_view(stubbed_api_gateway, stubbed_lambda): +def test_can_build_resource_routes_for_single_view(stubbed_api_gateway, + stubbed_lambda): route_trie = { 'name': '', 'uri_path': '/', @@ -265,6 +266,179 @@ def test_can_build_resource_routes_for_single_view(stubbed_api_gateway, stubbed_ g.build_resources(route_trie) +def test_cors_adds_required_headers(stubbed_api_gateway, stubbed_lambda): + cors_route_entry = RouteEntry(None, 'index_view', '/', ['PUT'], + cors=True, + content_types=['application/json']) + route_trie = { + 'name': '', + 'uri_path': '/', + 'children': {}, + 'resource_id': 'parent-id', + 'parent_resource_id': None, + 'is_route': True, + 'route_entry': cors_route_entry, + } + gateway_client, gateway_stub = stubbed_api_gateway + lambda_client, lambda_stub = stubbed_lambda + gateway_stub.add_response( + 'put_method', + service_response={}, + expected_params={ + 'resourceId': 'parent-id', + 'authorizationType': 'NONE', + 'restApiId': 'rest-api-id', + 'httpMethod': 'PUT', + }) + gateway_stub.add_response( + 'put_integration', + service_response={}, + expected_params={ + 'httpMethod': 'PUT', + 'integrationHttpMethod': 'POST', + 'passthroughBehavior': 'NEVER', + 'requestTemplates': { + 'application/json': FULL_PASSTHROUGH, + }, + 'resourceId': 'parent-id', + 'restApiId': 'rest-api-id', + 'type': 'AWS', + 'uri': ('arn:aws:apigateway:us-west-2:lambda:path' + '/2015-03-31/functions/arn:aws:lambda:us-west' + '-2:123:function:name/invocations') + } + ) + gateway_stub.add_response( + 'put_method_response', + service_response={}, + expected_params={ + 'httpMethod': 'PUT', + 'resourceId': 'parent-id', + 'responseModels': { + 'application/json': 'Empty', + }, + 'restApiId': 'rest-api-id', + 'statusCode': '200', + 'responseParameters': {'method.response.header.Access-Control-Allow-Origin': False}, + } + ) + gateway_stub.add_response( + 'put_integration_response', + service_response={}, + expected_params={ + 'httpMethod': 'PUT', + 'resourceId': 'parent-id', + 'responseTemplates': { + 'application/json': '', + }, + 'responseParameters': {'method.response.header.Access-Control-Allow-Origin': "'*'"}, + 'restApiId': 'rest-api-id', + 'statusCode': '200', + } + ) + for error_cls in ALL_ERRORS: + gateway_stub.add_response( + 'put_method_response', + service_response={}, + expected_params={ + 'httpMethod': ANY, + 'resourceId': ANY, + 'responseModels': ANY, + 'restApiId': ANY, + 'statusCode': str(error_cls.STATUS_CODE), + 'responseParameters': { + 'method.response.header.Access-Control-Allow-Origin': False}, + } + ) + gateway_stub.add_response( + 'put_integration_response', + service_response={}, + expected_params={ + 'httpMethod': ANY, + 'resourceId': ANY, + 'responseTemplates': ANY, + 'restApiId': ANY, + 'selectionPattern': '%s.*' % error_cls.__name__, + 'statusCode': str(error_cls.STATUS_CODE), + 'responseParameters': { + 'method.response.header.Access-Control-Allow-Origin': "'*'", + } + } + ) + gateway_stub.add_response( + 'put_method', service_response={}, + expected_params={ + 'restApiId': ANY, + 'resourceId': ANY, + 'httpMethod': 'OPTIONS', + 'authorizationType': 'NONE', + } + ) + gateway_stub.add_response( + 'put_integration', service_response={}, + expected_params={ + 'restApiId': ANY, + 'resourceId': ANY, + 'httpMethod': 'OPTIONS', + 'type': 'MOCK', + 'requestTemplates': {'application/json': '{"statusCode": 200}'} + } + ) + gateway_stub.add_response( + 'put_method_response', service_response={}, + expected_params={ + 'restApiId': ANY, + 'resourceId': ANY, + 'httpMethod': 'OPTIONS', + 'statusCode': '200', + 'responseModels': ANY, + 'responseParameters': { + 'method.response.header.Access-Control-Allow-Origin': False, + 'method.response.header.Access-Control-Allow-Methods': False, + 'method.response.header.Access-Control-Allow-Headers': False, + } + } + ) + gateway_stub.add_response( + 'put_integration_response', service_response={}, + expected_params={ + 'restApiId': ANY, + 'resourceId': ANY, + 'httpMethod': 'OPTIONS', + 'statusCode': '200', + 'responseTemplates': ANY, + 'responseParameters': { + 'method.response.header.Access-Control-Allow-Origin': "'*'", + 'method.response.header.Access-Control-Allow-Methods': ( + "'PUT,OPTIONS'" + ), + 'method.response.header.Access-Control-Allow-Headers': ( + "'Content-Type,X-Amz-Date,Authorization,X-Api-Key" + ",X-Amz-Security-Token'" + ), + } + } + ) + lambda_stub.add_response( + 'add_permission', + service_response={}, + expected_params={ + 'Action': 'lambda:InvokeFunction', + 'FunctionName': 'name', + 'Principal': 'apigateway.amazonaws.com', + 'SourceArn': 'arn:aws:execute-api:us-west-2:123:rest-api-id/*', + 'StatementId': 'random-id', + } + ) + g = APIGatewayResourceCreator( + gateway_client, lambda_client, + 'rest-api-id', 'arn:aws:lambda:us-west-2:123:function:name', + random_id_generator=lambda: "random-id") + gateway_stub.activate() + lambda_stub.activate() + g.build_resources(route_trie) + + def test_can_deploy_apig_and_lambda(sample_app): lambda_deploy = mock.Mock(spec=LambdaDeployer) apig_deploy = mock.Mock(spec=APIGatewayDeployer) From 3c18b2df0520950a7fb8b0cff2575435b2dc85ad Mon Sep 17 00:00:00 2001 From: James Saryerwinnie Date: Mon, 10 Oct 2016 09:43:24 -0700 Subject: [PATCH 10/10] Add docs for CORS support --- README.rst | 46 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index f741e9add..1afbd1279 100644 --- a/README.rst +++ b/README.rst @@ -635,6 +635,50 @@ the raw body bytes: you will need to use ``app.current_request.raw_body`` and parse the request body as needed. +Tutorial: CORS Support +====================== + +You can specify whether a view supports CORS by adding the +``cors=True`` parameter to your ``@app.route()`` call. By +default this value is false: + +.. code-block:: python + + @app.route('/supports-cors', methods=['PUT'], cors=True) + def supports_cors(): + return {} + + +Settings ``cors=True`` has similar behavior to enabling CORS +using the AWS Console. This includes: + +* Injecting the ``Access-Control-Allow-Origin: *`` header to your + responses, including all error responses you can return. +* Automatically adding an ``OPTIONS`` method so support preflighting + requests. + +The preflight request will return a response that includes: + +* ``Access-Control-Allow-Origin: *`` +* The ``Access-Control-Allow-Methods`` header will return a list of all HTTP + methods you've called out in your view function. In the example above, + this will be ``PUT,OPTIONS``. +* ``Access-Control-Allow-Headers: Content-Type,X-Amz-Date,Authorization, + X-Api-Key,X-Amz-Security-Token``. + +There's a couple of things to keep in mind when enabling cors for a view: + +* An ``OPTIONS`` method for preflighting is always injected. Ensure that + you don't have ``OPTIONS`` in the ``methods=[...]`` list of your + view function. +* Every view function must explicitly enable CORS support. +* There's no support for customizing the CORS configuration. + +The last two points will change in the future. See +`this issue +`_ +for more information. + Tutorial: Policy Generation =========================== @@ -785,7 +829,7 @@ auto policy generator detects actions that it would like to add or remove:: .. quick-start-end Tutorial: Using Custom Authentication -=========================== +===================================== AWS API Gateway routes can be authenticated in multiple ways: - API Key