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 diff --git a/chalice/app.py b/chalice/app.py index 54be8337f..889445d16 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,10 @@ 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..f316cec9c 100644 --- a/chalice/app.pyi +++ b/chalice/app.pyi @@ -41,15 +41,22 @@ 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, authorizer_id: str=None, api_key_required: bool=None, - content_types: List[str]=None) -> None: ... + content_types: List[str]=None, + cors: bool=False) -> None: ... def _parse_view_args(self) -> List[str]: ... 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 e9fe37690..578aa3fc2 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 @@ -299,6 +293,9 @@ 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, 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 @@ -358,37 +355,111 @@ def _configure_resource_route(self, node, http_method): uri=self._lambda_uri() ) # Success case. - c.put_integration_response( + 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) + self._add_error_responses(http_method, node, route_entry, c) + + 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, + '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} + client.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': + "'*'"} + client.put_integration_response(**integration_response_args) + + def _add_options_preflight_request(self, node, http_methods): + # 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 + # the view function to support an OPTIONs method. + c = self.client + c.put_method( restApiId=self.rest_api_id, resourceId=node['resource_id'], - httpMethod=http_method, - statusCode='200', - responseTemplates={'application/json': ''}, + 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=http_method, + 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, + }, + ) + 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'], + httpMethod='OPTIONS', + statusCode='200', + responseTemplates={'application/json': ''}, + responseParameters={ + "method.response.header.Access-Control-Allow-Origin": "'*'", + "method.response.header.Access-Control-Allow-Methods": ( + "'%s'" % allowed_methods), + "method.response.header.Access-Control-Allow-Headers": ( + "'Content-Type,X-Amz-Date,Authorization,X-Api-Key" + ",X-Amz-Security-Token'") + }, ) - # 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'}, - ) def _lambda_uri(self): # type: () -> str @@ -404,7 +475,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 +575,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 +629,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 +787,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/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 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} diff --git a/tests/unit/test_deployer.py b/tests/unit/test_deployer.py index 65e426d4a..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,35 +147,36 @@ 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_integration_response', + 'put_method_response', service_response={}, expected_params={ - 'httpMethod': 'POST', + 'httpMethod': http_method, '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', + 'httpMethod': http_method, '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), } ) -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': '/', @@ -221,6 +222,19 @@ def test_can_build_resource_routes_for_single_view(stubbed_api_gateway, stubbed_ '-2:123:function:name/invocations') } ) + gateway_stub.add_response( + 'put_method_response', + service_response={}, + expected_params={ + 'httpMethod': 'POST', + 'resourceId': 'parent-id', + 'responseModels': { + 'application/json': 'Empty', + }, + 'restApiId': 'rest-api-id', + 'statusCode': '200', + } + ) gateway_stub.add_response( 'put_integration_response', service_response={}, @@ -234,21 +248,177 @@ def test_can_build_resource_routes_for_single_view(stubbed_api_gateway, stubbed_ 'statusCode': '200', } ) + for error_cls in ALL_ERRORS: + add_expected_calls_to_map_error(error_cls, gateway_stub) + 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', + } + ) + gateway_stub.activate() + lambda_stub.activate() + 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': 'POST', + '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: - add_expected_calls_to_map_error(error_cls, gateway_stub) + 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={}, @@ -260,6 +430,10 @@ def test_can_build_resource_routes_for_single_view(stubbed_api_gateway, stubbed_ '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)