diff --git a/samcli/local/apigw/local_apigw_service.py b/samcli/local/apigw/local_apigw_service.py index ec4a01c942a7..93a0b5bc2987 100644 --- a/samcli/local/apigw/local_apigw_service.py +++ b/samcli/local/apigw/local_apigw_service.py @@ -6,7 +6,7 @@ from flask import Flask, request -from samcli.local.services.base_local_service import BaseLocalService, LambdaOutputParser +from samcli.local.services.base_local_service import BaseLocalService, LambdaOutputParser, CaseInsensitiveDict from samcli.local.lambdafn.exceptions import FunctionNotFound from samcli.local.events.api_event import ContextIdentity, RequestContext, ApiGatewayLambdaEvent from .service_error_responses import ServiceErrorResponses @@ -15,23 +15,6 @@ LOG = logging.getLogger(__name__) -class CaseInsensitiveDict(dict): - """ - Implement a simple case insensitive dictionary for storing headers. To preserve the original - case of the given Header (e.g. X-FooBar-Fizz) this only touches the get and contains magic - methods rather than implementing a __setitem__ where we normalize the case of the headers. - """ - - def __getitem__(self, key): - matches = [v for k, v in self.items() if k.lower() == key.lower()] - if not matches: - raise KeyError(key) - return matches[0] - - def __contains__(self, key): - return key.lower() in [k.lower() for k in self.keys()] - - class Route(object): def __init__(self, methods, function_name, path, binary_types=None): @@ -158,7 +141,7 @@ def _request_handler(self, **kwargs): except FunctionNotFound: return ServiceErrorResponses.lambda_not_found_response() - lambda_response, lambda_logs = LambdaOutputParser.get_lambda_output(stdout_stream) + lambda_response, lambda_logs, _ = LambdaOutputParser.get_lambda_output(stdout_stream) if self.stderr and lambda_logs: # Write the logs to stderr if available. @@ -173,7 +156,7 @@ def _request_handler(self, **kwargs): "statusCode in the response object). Response received: %s", lambda_response) return ServiceErrorResponses.lambda_failure_response() - return self._service_response(body, headers, status_code) + return self.service_response(body, headers, status_code) def _get_current_route(self, flask_request): """ diff --git a/samcli/local/lambda_service/lambda_error_responses.py b/samcli/local/lambda_service/lambda_error_responses.py new file mode 100644 index 000000000000..c9cb3eedf264 --- /dev/null +++ b/samcli/local/lambda_service/lambda_error_responses.py @@ -0,0 +1,242 @@ +"""Common Lambda Error Responses""" + +import json +from collections import OrderedDict + +from samcli.local.services.base_local_service import BaseLocalService + + +class LambdaErrorResponses(object): + + # The content type of the Invoke request body is not JSON. + UnsupportedMediaTypeException = ('UnsupportedMediaType', 415) + + # The AWS Lambda service encountered an internal error. + ServiceException = ('Service', 500) + + # The resource (for example, a Lambda function or access policy statement) specified in the request does not exist. + ResourceNotFoundException = ('ResourceNotFound', 404) + + # The request body could not be parsed as JSON. + InvalidRequestContentException = ('InvalidRequestContent', 400) + + NotImplementedException = ('NotImplemented', 501) + + PathNotFoundException = ('PathNotFoundLocally', 404) + + MethodNotAllowedException = ('MethodNotAllowedLocally', 405) + + # Error Types + USER_ERROR = "User" + SERVICE_ERROR = "Service" + LOCAL_SERVICE_ERROR = "LocalService" + + # Header Information + CONTENT_TYPE = 'application/json' + CONTENT_TYPE_HEADER_KEY = 'Content-Type' + + @staticmethod + def resource_not_found(function_name): + """ + Creates a Lambda Service ResourceNotFound Response + + Parameters + ---------- + function_name str + Name of the function that was requested to invoke + + Returns + ------- + Flask.Response + A response object representing the ResourceNotFound Error + """ + exception_tuple = LambdaErrorResponses.ResourceNotFoundException + + return BaseLocalService.service_response( + LambdaErrorResponses._construct_error_response_body( + LambdaErrorResponses.USER_ERROR, + "Function not found: arn:aws:lambda:us-west-2:012345678901:function:{}".format(function_name) + ), + LambdaErrorResponses._construct_headers(exception_tuple[0]), + exception_tuple[1] + ) + + @staticmethod + def invalid_request_content(message): + """ + Creates a Lambda Service InvalidRequestContent Response + + Parameters + ---------- + message str + Message to be added to the body of the response + + Returns + ------- + Flask.Response + A response object representing the InvalidRequestContent Error + """ + exception_tuple = LambdaErrorResponses.InvalidRequestContentException + + return BaseLocalService.service_response( + LambdaErrorResponses._construct_error_response_body(LambdaErrorResponses.USER_ERROR, message), + LambdaErrorResponses._construct_headers(exception_tuple[0]), + exception_tuple[1] + ) + + @staticmethod + def unsupported_media_type(content_type): + """ + Creates a Lambda Service UnsupportedMediaType Response + + Parameters + ---------- + content_type str + Content Type of the request that was made + + Returns + ------- + Flask.Response + A response object representing the UnsupportedMediaType Error + """ + exception_tuple = LambdaErrorResponses.UnsupportedMediaTypeException + + return BaseLocalService.service_response( + LambdaErrorResponses._construct_error_response_body(LambdaErrorResponses.USER_ERROR, + "Unsupported content type: {}".format(content_type)), + LambdaErrorResponses._construct_headers(exception_tuple[0]), + exception_tuple[1] + ) + + @staticmethod + def generic_service_exception(*args): + """ + Creates a Lambda Service Generic ServiceException Response + + Parameters + ---------- + args list + List of arguments Flask passes to the method + + Returns + ------- + Flask.Response + A response object representing the GenericServiceException Error + """ + exception_tuple = LambdaErrorResponses.ServiceException + + return BaseLocalService.service_response( + LambdaErrorResponses._construct_error_response_body(LambdaErrorResponses.SERVICE_ERROR, "ServiceException"), + LambdaErrorResponses._construct_headers(exception_tuple[0]), + exception_tuple[1] + ) + + @staticmethod + def not_implemented_locally(message): + """ + Creates a Lambda Service NotImplementedLocally Response + + Parameters + ---------- + message str + Message to be added to the body of the response + + Returns + ------- + Flask.Response + A response object representing the NotImplementedLocally Error + """ + exception_tuple = LambdaErrorResponses.NotImplementedException + + return BaseLocalService.service_response( + LambdaErrorResponses._construct_error_response_body(LambdaErrorResponses.LOCAL_SERVICE_ERROR, message), + LambdaErrorResponses._construct_headers(exception_tuple[0]), + exception_tuple[1] + ) + + @staticmethod + def generic_path_not_found(*args): + """ + Creates a Lambda Service Generic PathNotFound Response + + Parameters + ---------- + args list + List of arguments Flask passes to the method + + Returns + ------- + Flask.Response + A response object representing the GenericPathNotFound Error + """ + exception_tuple = LambdaErrorResponses.PathNotFoundException + + return BaseLocalService.service_response( + LambdaErrorResponses._construct_error_response_body( + LambdaErrorResponses.LOCAL_SERVICE_ERROR, "PathNotFoundException"), + LambdaErrorResponses._construct_headers(exception_tuple[0]), + exception_tuple[1] + ) + + @staticmethod + def generic_method_not_allowed(*args): + """ + Creates a Lambda Service Generic MethodNotAllowed Response + + Parameters + ---------- + args list + List of arguments Flask passes to the method + + Returns + ------- + Flask.Response + A response object representing the GenericMethodNotAllowed Error + """ + exception_tuple = LambdaErrorResponses.MethodNotAllowedException + + return BaseLocalService.service_response( + LambdaErrorResponses._construct_error_response_body(LambdaErrorResponses.LOCAL_SERVICE_ERROR, + "MethodNotAllowedException"), + LambdaErrorResponses._construct_headers(exception_tuple[0]), + exception_tuple[1] + ) + + @staticmethod + def _construct_error_response_body(error_type, error_message): + """ + Constructs a string to be used in the body of the Response that conforms + to the structure of the Lambda Service Responses + + Parameters + ---------- + error_type str + The type of error + error_message str + Message of the error that occured + + Returns + ------- + str + str representing the response body + """ + # OrderedDict is used to make testing in Py2 and Py3 consistent + return json.dumps(OrderedDict([("Type", error_type), ("Message", error_message)])) + + @staticmethod + def _construct_headers(error_type): + """ + Constructs Headers for the Local Lambda Error Response + + Parameters + ---------- + error_type str + Error type that occurred to be put into the 'x-amzn-errortype' header + + Returns + ------- + dict + Dict representing the Lambda Error Response Headers + """ + return {'x-amzn-errortype': error_type, + 'Content-Type': 'application/json'} diff --git a/samcli/local/lambda_service/local_lambda_invoke_service.py b/samcli/local/lambda_service/local_lambda_invoke_service.py index 4b65ec60e768..204e7a88cb98 100644 --- a/samcli/local/lambda_service/local_lambda_invoke_service.py +++ b/samcli/local/lambda_service/local_lambda_invoke_service.py @@ -1,13 +1,15 @@ """Local Lambda Service that only invokes a function""" +import json import logging import io from flask import Flask, request -from samcli.local.services.base_local_service import BaseLocalService, LambdaOutputParser +from samcli.local.services.base_local_service import BaseLocalService, LambdaOutputParser, CaseInsensitiveDict from samcli.local.lambdafn.exceptions import FunctionNotFound +from .lambda_error_responses import LambdaErrorResponses LOG = logging.getLogger(__name__) @@ -46,14 +48,76 @@ def create(self): methods=['POST'], provide_automatic_options=False) + # setup request validation before Flask calls the view_func + self._app.before_request(LocalLambdaInvokeService.validate_request) + self._construct_error_handling() + @staticmethod + def validate_request(): + """ + Validates the incoming request + + The following are invalid + 1. The Request data is not json serializable + 2. Query Parameters are sent to the endpoint + 3. The Request Content-Type is not application/json + 4. 'X-Amz-Log-Type' header is not 'None' + 5. 'X-Amz-Invocation-Type' header is not 'RequestResponse' + + Returns + ------- + flask.Response + If the request is not valid a flask Response is returned + + None: + If the request passes all validation + """ + flask_request = request + request_data = flask_request.get_data() + + if not request_data: + request_data = b'{}' + + request_data = request_data.decode('utf-8') + + try: + json.loads(request_data) + except ValueError as json_error: + LOG.debug("Request body was not json.") + return LambdaErrorResponses.invalid_request_content( + "Could not parse request body into json: {}".format(str(json_error))) + + if flask_request.args: + LOG.debug("Query parameters are in the request but not supported") + return LambdaErrorResponses.invalid_request_content("Query Parameters are not supported") + + if flask_request.content_type and flask_request.content_type.lower() != "application/json": + LOG.debug("%s media type is not supported. Must be application/json", flask_request.content_type) + return LambdaErrorResponses.unsupported_media_type(flask_request.content_type) + + request_headers = CaseInsensitiveDict(flask_request.headers) + + log_type = request_headers.get('X-Amz-Log-Type', 'None') + if log_type != 'None': + LOG.debug("log-type: %s is not supported. None is only supported.", log_type) + return LambdaErrorResponses.not_implemented_locally( + "log-type: {} is not supported. None is only supported.".format(log_type)) + + invocation_type = request_headers.get('X-Amz-Invocation-Type', 'RequestResponse') + if invocation_type != 'RequestResponse': + LOG.warning("invocation-type: %s is not supported. RequestResponse is only supported.", invocation_type) + return LambdaErrorResponses.not_implemented_locally( + "invocation-type: {} is not supported. RequestResponse is only supported.".format(invocation_type)) + def _construct_error_handling(self): """ Updates the Flask app with Error Handlers for different Error Codes """ - pass + self._app.register_error_handler(500, LambdaErrorResponses.generic_service_exception) + self._app.register_error_handler(404, LambdaErrorResponses.generic_path_not_found) + self._app.register_error_handler(405, LambdaErrorResponses.generic_method_not_allowed) def _invoke_request_handler(self, function_name): """ @@ -68,23 +132,34 @@ def _invoke_request_handler(self, function_name): Returns ------- A Flask Response response object as if it was returned from Lambda - """ flask_request = request - request_data = flask_request.get_data().decode('utf-8') + + request_data = flask_request.get_data() + + if not request_data: + request_data = b'{}' + + request_data = request_data.decode('utf-8') stdout_stream = io.BytesIO() try: self.lambda_runner.invoke(function_name, request_data, stdout=stdout_stream, stderr=self.stderr) except FunctionNotFound: - # TODO Change this - raise Exception('Change this later') + LOG.debug('%s was not found to invoke.', function_name) + return LambdaErrorResponses.resource_not_found(function_name) - lambda_response, lambda_logs = LambdaOutputParser.get_lambda_output(stdout_stream) + lambda_response, lambda_logs, is_lambda_user_error_response = \ + LambdaOutputParser.get_lambda_output(stdout_stream) if self.stderr and lambda_logs: # Write the logs to stderr if available. self.stderr.write(lambda_logs) - return self._service_response(lambda_response, {'Content-Type': 'application/json'}, 200) + if is_lambda_user_error_response: + return self.service_response(lambda_response, + {'Content-Type': 'application/json', 'x-amz-function-error': 'Unhandled'}, + 200) + + return self.service_response(lambda_response, {'Content-Type': 'application/json'}, 200) diff --git a/samcli/local/services/base_local_service.py b/samcli/local/services/base_local_service.py index 9a2874bc7f26..cf6a0cc9a519 100644 --- a/samcli/local/services/base_local_service.py +++ b/samcli/local/services/base_local_service.py @@ -1,5 +1,6 @@ """Base class for all Services that interact with Local Lambda""" +import json import logging import os @@ -8,6 +9,23 @@ LOG = logging.getLogger(__name__) +class CaseInsensitiveDict(dict): + """ + Implement a simple case insensitive dictionary for storing headers. To preserve the original + case of the given Header (e.g. X-FooBar-Fizz) this only touches the get and contains magic + methods rather than implementing a __setitem__ where we normalize the case of the headers. + """ + + def __getitem__(self, key): + matches = [v for k, v in self.items() if k.lower() == key.lower()] + if not matches: + raise KeyError(key) + return matches[0] + + def __contains__(self, key): + return key.lower() in [k.lower() for k in self.keys()] + + class BaseLocalService(object): def __init__(self, is_debugging, port, host): @@ -63,7 +81,7 @@ def run(self): self._app.run(threaded=multi_threaded, host=self.host, port=self.port) @staticmethod - def _service_response(body, headers, status_code): + def service_response(body, headers, status_code): """ Constructs a Flask Response from the body, headers, and status_code. @@ -98,6 +116,8 @@ def get_lambda_output(stdout_stream): String data containing response from Lambda function str String data containng logs statements, if any. + bool + If the response is an error/exception from the container """ # We only want the last line of stdout, because it's possible that # the function may have written directly to stdout using @@ -118,4 +138,47 @@ def get_lambda_output(stdout_stream): # Last line is Lambda response. Make sure to strip() so we get rid of extra whitespaces & newlines around lambda_response = stdout_data[last_line_position:].strip() - return lambda_response, lambda_logs + lambda_response = lambda_response.decode('utf-8') + + # When the Lambda Function returns an Error/Exception, the output is added to the stdout of the container. From + # our perspective, the container returned some value, which is not always true. Since the output is the only + # information we have, we need to inspect this to understand if the container returned a some data or raised an + # error + is_lambda_user_error_response = LambdaOutputParser.is_lambda_error_response(lambda_response) + + return lambda_response, lambda_logs, is_lambda_user_error_response + + @staticmethod + def is_lambda_error_response(lambda_response): + """ + Check to see if the output from the container is in the form of an Error/Exception from the Lambda invoke + + Parameters + ---------- + lambda_response str + The response the container returned + + Returns + ------- + bool + True if the output matches the Error/Exception Dictionary otherwise False + """ + is_lambda_user_error_response = False + try: + lambda_response_dict = json.loads(lambda_response) + + # This is a best effort attempt to determine if the output (lambda_response) from the container was an + # Error/Exception that was raised/returned/thrown from the container. To ensure minimal false positives in + # this checking, we check for all three keys that can occur in Lambda raised/thrown/returned an + # Error/Exception. This still risks false positives when the data returned matches exactly a dictionary with + # the keys 'errorMessage', 'errorType' and 'stackTrace'. + if isinstance(lambda_response_dict, dict) and \ + len(lambda_response_dict) == 3 and \ + 'errorMessage' in lambda_response_dict and \ + 'errorType' in lambda_response_dict and \ + 'stackTrace' in lambda_response_dict: + is_lambda_user_error_response = True + except ValueError: + # If you can't serialize the output into a dict, then do nothing + pass + return is_lambda_user_error_response diff --git a/tests/functional/function_code.py b/tests/functional/function_code.py index 80d88738263a..bff31b464167 100644 --- a/tests/functional/function_code.py +++ b/tests/functional/function_code.py @@ -47,6 +47,13 @@ }; """ +THROW_ERROR_LAMBDA = """ +exports.handler = function(event, context, callback) { + var error = new Error("something is wrong"); + callback(error); +}; +""" + API_GATEWAY_ECHO_EVENT = """ exports.handler = function(event, context, callback){ diff --git a/tests/functional/local/lambda_service/test_local_lambda_invoke.py b/tests/functional/local/lambda_service/test_local_lambda_invoke.py index e6dc0e827726..2e409b03da51 100644 --- a/tests/functional/local/lambda_service/test_local_lambda_invoke.py +++ b/tests/functional/local/lambda_service/test_local_lambda_invoke.py @@ -5,38 +5,60 @@ import time from unittest import TestCase import os +import sys import requests from samcli.local.lambda_service.local_lambda_invoke_service import LocalLambdaInvokeService -from tests.functional.function_code import nodejs_lambda, HELLO_FROM_LAMBDA, ECHO_CODE +from tests.functional.function_code import nodejs_lambda, HELLO_FROM_LAMBDA, ECHO_CODE, THROW_ERROR_LAMBDA from samcli.commands.local.lib import provider from samcli.local.lambdafn.runtime import LambdaRuntime from samcli.commands.local.lib.local_lambda import LocalLambdaRunner from samcli.local.docker.manager import ContainerManager +from samcli.local.lambdafn.exceptions import FunctionNotFound class TestLocalLambdaService(TestCase): + + @classmethod + def mocked_function_provider(cls, function_name): + if function_name == "HelloWorld": + return cls.hello_world_function + if function_name == "ThrowError": + return cls.throw_error_function + else: + raise FunctionNotFound("Could not find Function") + @classmethod def setUpClass(cls): + cls.code_abs_path_for_throw_error = nodejs_lambda(THROW_ERROR_LAMBDA) + + # Let's convert this absolute path to relative path. Let the parent be the CWD, and codeuri be the folder + cls.cwd_for_throw_error = os.path.dirname(cls.code_abs_path_for_throw_error) + cls.code_uri_for_throw_error = os.path.relpath(cls.code_abs_path_for_throw_error, cls.cwd_for_throw_error) # Get relative path with respect to CWD + cls.code_abs_path = nodejs_lambda(HELLO_FROM_LAMBDA) # Let's convert this absolute path to relative path. Let the parent be the CWD, and codeuri be the folder cls.cwd = os.path.dirname(cls.code_abs_path) cls.code_uri = os.path.relpath(cls.code_abs_path, cls.cwd) # Get relative path with respect to CWD - cls.function_name = "HelloWorld" + cls.hello_world_function_name = "HelloWorld" - cls.function = provider.Function(name=cls.function_name, runtime="nodejs4.3", memory=256, timeout=5, - handler="index.handler", codeuri=cls.code_uri, environment=None, - rolearn=None) + cls.hello_world_function = provider.Function(name=cls.hello_world_function_name, runtime="nodejs4.3", + memory=256, timeout=5, handler="index.handler", + codeuri=cls.code_uri, environment=None, rolearn=None) - cls.mock_function_provider = Mock() - cls.mock_function_provider.get.return_value = cls.function + cls.throw_error_function_name = "ThrowError" - list_of_function_names = ['HelloWorld'] + cls.throw_error_function = provider.Function(name=cls.throw_error_function_name, runtime="nodejs4.3", + memory=256, timeout=5, handler="index.handler", + codeuri=cls.code_uri_for_throw_error, environment=None, rolearn=None) - cls.service, cls.port, cls.url, cls.scheme = make_service(list_of_function_names, cls.mock_function_provider, cls.cwd) + cls.mock_function_provider = Mock() + cls.mock_function_provider.get.side_effect = cls.mocked_function_provider + + cls.service, cls.port, cls.url, cls.scheme = make_service(cls.mock_function_provider, cls.cwd) cls.service.create() t = threading.Thread(name='thread', target=cls.service.run, args=()) t.setDaemon(True) @@ -46,12 +68,13 @@ def setUpClass(cls): @classmethod def tearDownClass(cls): shutil.rmtree(cls.code_abs_path) + shutil.rmtree(cls.code_abs_path_for_throw_error) def setUp(self): # Print full diff when comparing large dictionaries self.maxDiff = None - def test_mock_response_is_returned(self): + def test_lambda_str_response_is_returned(self): expected = 'Hello from Lambda' response = requests.post(self.url + '/2015-03-31/functions/HelloWorld/invocations') @@ -61,6 +84,31 @@ def test_mock_response_is_returned(self): self.assertEquals(actual, expected) self.assertEquals(response.status_code, 200) + def test_request_with_non_existing_function(self): + expected_data = {"Message": "Function not found: arn:aws:lambda:us-west-2:012345678901:function:{}".format('IDoNotExist'), + "Type": "User"} + + response = requests.post(self.url + '/2015-03-31/functions/IDoNotExist/invocations') + + actual_data = response.json() + acutal_error_type_header = response.headers.get('x-amzn-errortype') + + self.assertEquals(actual_data, expected_data) + self.assertEquals(acutal_error_type_header, 'ResourceNotFound') + self.assertEquals(response.status_code, 404) + + def test_request_a_function_that_throws_an_error(self): + expected_data = {'errorMessage': 'something is wrong', 'errorType': 'Error','stackTrace': ['exports.handler (/var/task/index.js:3:17)']} + + response = requests.post(self.url + '/2015-03-31/functions/ThrowError/invocations') + + actual_data = response.json() + acutal_error_type_header = response.headers.get('x-amz-function-error') + + self.assertEquals(actual_data, expected_data) + self.assertEquals(acutal_error_type_header, 'Unhandled') + self.assertEquals(response.status_code, 200) + class TestLocalEchoLambdaService(TestCase): @classmethod @@ -80,9 +128,7 @@ def setUpClass(cls): cls.mock_function_provider = Mock() cls.mock_function_provider.get.return_value = cls.function - list_of_function_names = ['HelloWorld'] - - cls.service, cls.port, cls.url, cls.scheme = make_service(list_of_function_names, cls.mock_function_provider, cls.cwd) + cls.service, cls.port, cls.url, cls.scheme = make_service(cls.mock_function_provider, cls.cwd) cls.service.create() t = threading.Thread(name='thread', target=cls.service.run, args=()) t.setDaemon(True) @@ -107,8 +153,169 @@ def test_mock_response_is_returned(self): self.assertEquals(actual, expected) self.assertEquals(response.status_code, 200) + def test_function_executed_when_no_data_provided(self): + expected = {} + + response = requests.post(self.url + '/2015-03-31/functions/HelloWorld/invocations') + + actual = response.json() + + self.assertEquals(actual, expected) + self.assertEquals(response.status_code, 200) + + +class TestLocalLambdaService_NotSupportedRequests(TestCase): + @classmethod + def setUpClass(cls): + cls.code_abs_path = nodejs_lambda(ECHO_CODE) + + # Let's convert this absolute path to relative path. Let the parent be the CWD, and codeuri be the folder + cls.cwd = os.path.dirname(cls.code_abs_path) + cls.code_uri = os.path.relpath(cls.code_abs_path, cls.cwd) # Get relative path with respect to CWD + + cls.function_name = "HelloWorld" + + cls.function = provider.Function(name=cls.function_name, runtime="nodejs4.3", memory=256, timeout=5, + handler="index.handler", codeuri=cls.code_uri, environment=None, + rolearn=None) + + cls.mock_function_provider = Mock() + cls.mock_function_provider.get.return_value = cls.function + + cls.service, cls.port, cls.url, cls.scheme = make_service(cls.mock_function_provider, cls.cwd) + cls.service.create() + # import pdb; pdb.set_trace() + t = threading.Thread(name='thread', target=cls.service.run, args=()) + t.setDaemon(True) + t.start() + time.sleep(1) + + @classmethod + def tearDownClass(cls): + shutil.rmtree(cls.code_abs_path) + + def setUp(self): + # Print full diff when comparing large dictionaries + self.maxDiff = None + + def test_query_string_parameters_in_request(self): + expected = {"Type": "User", + "Message": "Query Parameters are not supported"} + + response = requests.post(self.url + '/2015-03-31/functions/HelloWorld/invocations', json={"key1": "value1"}, params={"key": "value"}) + + actual = response.json() + + self.assertEquals(actual, expected) + self.assertEquals(response.status_code, 400) + self.assertEquals(response.headers.get('x-amzn-errortype'), 'InvalidRequestContent') + self.assertEquals(response.headers.get('Content-Type'),'application/json') + + def test_payload_is_not_json_serializable(self): + if sys.version_info.major == 2: + expected = {"Type": "User", + "Message": "Could not parse request body into json: No JSON object could be decoded"} + else: + expected = {"Type": "User", + "Message": "Could not parse request body into json: Expecting value: line 1 column 1 (char 0)"} + + response = requests.post(self.url + '/2015-03-31/functions/HelloWorld/invocations', data='notat:asdfasdf') + + actual = response.json() + + self.assertEquals(actual, expected) + self.assertEquals(response.status_code, 400) + self.assertEquals(response.headers.get('x-amzn-errortype'), 'InvalidRequestContent') + self.assertEquals(response.headers.get('Content-Type'), 'application/json') + + def test_log_type_tail_in_request(self): + expected = {"Type": "LocalService", "Message": "log-type: Tail is not supported. None is only supported."} + + response = requests.post(self.url + '/2015-03-31/functions/HelloWorld/invocations', headers={'X-Amz-Log-Type': 'Tail'}) + + actual = response.json() + + self.assertEquals(actual, expected) + self.assertEquals(response.status_code, 501) + self.assertEquals(response.headers.get('Content-Type'), 'application/json') + self.assertEquals(response.headers.get('x-amzn-errortype'), 'NotImplemented') + + def test_log_type_tail_in_request_with_lowercase_header(self): + expected = {"Type": "LocalService", "Message": "log-type: Tail is not supported. None is only supported."} + + response = requests.post(self.url + '/2015-03-31/functions/HelloWorld/invocations', headers={'x-amz-log-type': 'Tail'}) + + actual = response.json() + + self.assertEquals(actual, expected) + self.assertEquals(response.status_code, 501) + self.assertEquals(response.headers.get('Content-Type'), 'application/json') + self.assertEquals(response.headers.get('x-amzn-errortype'), 'NotImplemented') + + def test_invocation_type_event_in_request(self): + expected = {"Type": "LocalService", "Message": "invocation-type: Event is not supported. RequestResponse is only supported."} + + response = requests.post(self.url + '/2015-03-31/functions/HelloWorld/invocations', + headers={'X-Amz-Invocation-Type': 'Event'}) + + actual = response.json() + + self.assertEquals(actual, expected) + self.assertEquals(response.status_code, 501) + self.assertEquals(response.headers.get('Content-Type'), 'application/json') + self.assertEquals(response.headers.get('x-amzn-errortype'), 'NotImplemented') + + def test_invocation_type_dry_run_in_request(self): + expected = {"Type": "LocalService", "Message": "invocation-type: DryRun is not supported. RequestResponse is only supported."} + + response = requests.post(self.url + '/2015-03-31/functions/HelloWorld/invocations', + headers={'X-Amz-Invocation-Type': 'DryRun'}) + + actual = response.json() + + self.assertEquals(actual, expected) + self.assertEquals(response.status_code, 501) + self.assertEquals(response.headers.get('Content-Type'), 'application/json') + self.assertEquals(response.headers.get('x-amzn-errortype'), 'NotImplemented') + + def test_media_type_is_not_application_json(self): + expected = {"Type": "User", + "Message": 'Unsupported content type: image/gif'} + + response = requests.post(self.url + '/2015-03-31/functions/HelloWorld/invocations', headers={'Content-Type':'image/gif'}) + + actual = response.json() + + self.assertEquals(actual, expected) + self.assertEquals(response.status_code, 415) + self.assertEquals(response.headers.get('x-amzn-errortype'), 'UnsupportedMediaType') + self.assertEquals(response.headers.get('Content-Type'), 'application/json') + + def test_generic_404_error_when_request_to_nonexisting_endpoint(self): + expected_data = {'Type': 'LocalService', 'Message': 'PathNotFoundException'} + + response = requests.post(self.url + '/some/random/path/that/does/not/exist') + + actual_data = response.json() + + self.assertEquals(actual_data, expected_data) + self.assertEquals(response.status_code, 404) + self.assertEquals(response.headers.get('x-amzn-errortype'), 'PathNotFoundLocally') + + + def test_generic_405_error_when_request_path_with_invalid_method(self): + expected_data = {'Type': 'LocalService', 'Message': 'MethodNotAllowedException'} + + response = requests.get(self.url + '/2015-03-31/functions/HelloWorld/invocations') + + actual_data = response.json() + + self.assertEquals(actual_data, expected_data) + self.assertEquals(response.status_code, 405) + self.assertEquals(response.headers.get('x-amzn-errortype'), 'MethodNotAllowedLocally') + -def make_service(list_of_function_names, function_provider, cwd): +def make_service(function_provider, cwd): port = random_port() manager = ContainerManager() local_runtime = LambdaRuntime(manager) diff --git a/tests/unit/local/apigw/test_local_apigw_service.py b/tests/unit/local/apigw/test_local_apigw_service.py index 3e741e21dc22..0adf10753358 100644 --- a/tests/unit/local/apigw/test_local_apigw_service.py +++ b/tests/unit/local/apigw/test_local_apigw_service.py @@ -5,7 +5,7 @@ from parameterized import parameterized, param -from samcli.local.apigw.local_apigw_service import LocalApigwService, Route, CaseInsensitiveDict +from samcli.local.apigw.local_apigw_service import LocalApigwService, Route from samcli.local.lambdafn.exceptions import FunctionNotFound @@ -29,7 +29,7 @@ def setUp(self): def test_request_must_invoke_lambda(self): make_response_mock = Mock() - self.service._service_response = make_response_mock + self.service.service_response = make_response_mock self.service._get_current_route = Mock() self.service._construct_event = Mock() @@ -39,7 +39,7 @@ def test_request_must_invoke_lambda(self): service_response_mock = Mock() service_response_mock.return_value = make_response_mock - self.service._service_response = service_response_mock + self.service.service_response = service_response_mock result = self.service._request_handler() @@ -54,7 +54,7 @@ def test_request_handler_returns_process_stdout_when_making_response(self, lambd make_response_mock = Mock() - self.service._service_response = make_response_mock + self.service.service_response = make_response_mock self.service._get_current_route = Mock() self.service._construct_event = Mock() @@ -64,11 +64,11 @@ def test_request_handler_returns_process_stdout_when_making_response(self, lambd lambda_logs = "logs" lambda_response = "response" - lambda_output_parser_mock.get_lambda_output.return_value = lambda_response, lambda_logs - + is_customer_error = False + lambda_output_parser_mock.get_lambda_output.return_value = lambda_response, lambda_logs, is_customer_error service_response_mock = Mock() service_response_mock.return_value = make_response_mock - self.service._service_response = service_response_mock + self.service.service_response = service_response_mock result = self.service._request_handler() @@ -83,7 +83,7 @@ def test_request_handler_returns_process_stdout_when_making_response(self, lambd def test_request_handler_returns_make_response(self): make_response_mock = Mock() - self.service._service_response = make_response_mock + self.service.service_response = make_response_mock self.service._get_current_route = Mock() self.service._construct_event = Mock() @@ -93,7 +93,7 @@ def test_request_handler_returns_make_response(self): service_response_mock = Mock() service_response_mock.return_value = make_response_mock - self.service._service_response = service_response_mock + self.service.service_response = service_response_mock result = self.service._request_handler() @@ -468,36 +468,3 @@ def test_should_base64_encode_returns_true(self, test_case_name, binary_types, m ]) def test_should_base64_encode_returns_false(self, test_case_name, binary_types, mimetype): self.assertFalse(LocalApigwService._should_base64_encode(binary_types, mimetype)) - - -class TestService_CaseInsensiveDict(TestCase): - - def setUp(self): - self.data = CaseInsensitiveDict({ - 'Content-Type': 'text/html', - 'Browser': 'APIGW', - }) - - def test_contains_lower(self): - self.assertTrue('content-type' in self.data) - - def test_contains_title(self): - self.assertTrue('Content-Type' in self.data) - - def test_contains_upper(self): - self.assertTrue('CONTENT-TYPE' in self.data) - - def test_contains_browser_key(self): - self.assertTrue('Browser' in self.data) - - def test_contains_not_in(self): - self.assertTrue('Dog-Food' not in self.data) - - def test_setitem_found(self): - self.data['Browser'] = 'APIGW' - - self.assertTrue(self.data['browser']) - - def test_keyerror(self): - with self.assertRaises(KeyError): - self.data['does-not-exist'] diff --git a/tests/unit/local/lambda_service/test_lambda_error_responses.py b/tests/unit/local/lambda_service/test_lambda_error_responses.py new file mode 100644 index 000000000000..3248cb4e4a06 --- /dev/null +++ b/tests/unit/local/lambda_service/test_lambda_error_responses.py @@ -0,0 +1,92 @@ +from unittest import TestCase +from mock import patch + +from samcli.local.lambda_service.lambda_error_responses import LambdaErrorResponses + + +class TestLambdaErrorResponses(TestCase): + + @patch('samcli.local.services.base_local_service.BaseLocalService.service_response') + def test_resource_not_found(self, service_response_mock): + service_response_mock.return_value = "ResourceNotFound" + + response = LambdaErrorResponses.resource_not_found('HelloFunction') + + self.assertEquals(response, 'ResourceNotFound') + service_response_mock.assert_called_once_with( + '{"Type": "User", "Message": "Function not found: ' + 'arn:aws:lambda:us-west-2:012345678901:function:HelloFunction"}', + {'x-amzn-errortype': 'ResourceNotFound', 'Content-Type': 'application/json'}, + 404) + + @patch('samcli.local.services.base_local_service.BaseLocalService.service_response') + def test_invalid_request_content(self, service_response_mock): + service_response_mock.return_value = "InvalidRequestContent" + + response = LambdaErrorResponses.invalid_request_content('InvalidRequestContent') + + self.assertEquals(response, 'InvalidRequestContent') + service_response_mock.assert_called_once_with( + '{"Type": "User", "Message": "InvalidRequestContent"}', + {'x-amzn-errortype': 'InvalidRequestContent', 'Content-Type': 'application/json'}, + 400) + + @patch('samcli.local.services.base_local_service.BaseLocalService.service_response') + def test_unsupported_media_type(self, service_response_mock): + service_response_mock.return_value = "UnsupportedMediaType" + + response = LambdaErrorResponses.unsupported_media_type('UnsupportedMediaType') + + self.assertEquals(response, 'UnsupportedMediaType') + service_response_mock.assert_called_once_with( + '{"Type": "User", "Message": "Unsupported content type: UnsupportedMediaType"}', + {'x-amzn-errortype': 'UnsupportedMediaType', 'Content-Type': 'application/json'}, + 415) + + @patch('samcli.local.services.base_local_service.BaseLocalService.service_response') + def test_generic_service_exception(self, service_response_mock): + service_response_mock.return_value = "GenericServiceException" + + response = LambdaErrorResponses.generic_service_exception('GenericServiceException') + + self.assertEquals(response, 'GenericServiceException') + service_response_mock.assert_called_once_with( + '{"Type": "Service", "Message": "ServiceException"}', + {'x-amzn-errortype': 'Service', 'Content-Type': 'application/json'}, + 500) + + @patch('samcli.local.services.base_local_service.BaseLocalService.service_response') + def test_not_implemented_locally(self, service_response_mock): + service_response_mock.return_value = "NotImplementedLocally" + + response = LambdaErrorResponses.not_implemented_locally('NotImplementedLocally') + + self.assertEquals(response, 'NotImplementedLocally') + service_response_mock.assert_called_once_with( + '{"Type": "LocalService", "Message": "NotImplementedLocally"}', + {'x-amzn-errortype': 'NotImplemented', 'Content-Type': 'application/json'}, + 501) + + @patch('samcli.local.services.base_local_service.BaseLocalService.service_response') + def test_generic_path_not_found(self, service_response_mock): + service_response_mock.return_value = "GenericPathNotFound" + + response = LambdaErrorResponses.generic_path_not_found('GenericPathNotFound') + + self.assertEquals(response, 'GenericPathNotFound') + service_response_mock.assert_called_once_with( + '{"Type": "LocalService", "Message": "PathNotFoundException"}', + {'x-amzn-errortype': 'PathNotFoundLocally', 'Content-Type': 'application/json'}, + 404) + + @patch('samcli.local.services.base_local_service.BaseLocalService.service_response') + def test_generic_method_not_allowed(self, service_response_mock): + service_response_mock.return_value = "GenericMethodNotAllowed" + + response = LambdaErrorResponses.generic_method_not_allowed('GenericMethodNotAllowed') + + self.assertEquals(response, 'GenericMethodNotAllowed') + service_response_mock.assert_called_once_with( + '{"Type": "LocalService", "Message": "MethodNotAllowedException"}', + {'x-amzn-errortype': 'MethodNotAllowedLocally', 'Content-Type': 'application/json'}, + 405) diff --git a/tests/unit/local/lambda_service/test_local_lambda_invoke_service.py b/tests/unit/local/lambda_service/test_local_lambda_invoke_service.py index dca78c5c25b9..172a21a3d34c 100644 --- a/tests/unit/local/lambda_service/test_local_lambda_invoke_service.py +++ b/tests/unit/local/lambda_service/test_local_lambda_invoke_service.py @@ -1,5 +1,6 @@ from unittest import TestCase -from mock import Mock, patch, ANY +from mock import Mock, patch, ANY, call +import sys from samcli.local.lambda_service.local_lambda_invoke_service import LocalLambdaInvokeService from samcli.local.lambdafn.exceptions import FunctionNotFound @@ -43,11 +44,11 @@ def test_create_service_endpoints(self, flask_mock, error_handling_mock): methods=['POST'], provide_automatic_options=False) - @patch('samcli.local.lambda_service.local_lambda_invoke_service.LocalLambdaInvokeService._service_response') + @patch('samcli.local.lambda_service.local_lambda_invoke_service.LocalLambdaInvokeService.service_response') @patch('samcli.local.lambda_service.local_lambda_invoke_service.LambdaOutputParser') @patch('samcli.local.lambda_service.local_lambda_invoke_service.request') def test_invoke_request_handler(self, request_mock, lambda_output_parser_mock, service_response_mock): - lambda_output_parser_mock.get_lambda_output.return_value = 'hello world', None + lambda_output_parser_mock.get_lambda_output.return_value = 'hello world', None, False service_response_mock.return_value = 'request response' request_mock.get_data.return_value = b'{}' @@ -61,19 +62,26 @@ def test_invoke_request_handler(self, request_mock, lambda_output_parser_mock, s lambda_runner_mock.invoke.assert_called_once_with('HelloWorld', '{}', stdout=ANY, stderr=None) service_response_mock.assert_called_once_with('hello world', {'Content-Type': 'application/json'}, 200) + @patch('samcli.local.lambda_service.local_lambda_invoke_service.LambdaErrorResponses') @patch('samcli.local.lambda_service.local_lambda_invoke_service.request') - def test_invoke_request_handler_on_incorrect_path(self, request_mock): + def test_invoke_request_handler_on_incorrect_path(self, request_mock, lambda_error_responses_mock): request_mock.get_data.return_value = b'{}' lambda_runner_mock = Mock() lambda_runner_mock.invoke.side_effect = FunctionNotFound + + lambda_error_responses_mock.resource_not_found.return_value = "Couldn't find Lambda" + service = LocalLambdaInvokeService(lambda_runner=lambda_runner_mock, port=3000, host='localhost') - with self.assertRaises(Exception): - service._invoke_request_handler(function_name='NotFound') + response = service._invoke_request_handler(function_name='NotFound') + + self.assertEquals(response, "Couldn't find Lambda") lambda_runner_mock.invoke.assert_called_once_with('NotFound', '{}', stdout=ANY, stderr=None) - @patch('samcli.local.lambda_service.local_lambda_invoke_service.LocalLambdaInvokeService._service_response') + lambda_error_responses_mock.resource_not_found.assert_called_once_with('NotFound') + + @patch('samcli.local.lambda_service.local_lambda_invoke_service.LocalLambdaInvokeService.service_response') @patch('samcli.local.lambda_service.local_lambda_invoke_service.LambdaOutputParser') @patch('samcli.local.lambda_service.local_lambda_invoke_service.request') def test_request_handler_returns_process_stdout_when_making_response(self, request_mock, lambda_output_parser_mock, @@ -82,7 +90,8 @@ def test_request_handler_returns_process_stdout_when_making_response(self, reque lambda_logs = "logs" lambda_response = "response" - lambda_output_parser_mock.get_lambda_output.return_value = lambda_response, lambda_logs + is_customer_error = False + lambda_output_parser_mock.get_lambda_output.return_value = lambda_response, lambda_logs, is_customer_error service_response_mock.return_value = 'request response' @@ -100,3 +109,164 @@ def test_request_handler_returns_process_stdout_when_making_response(self, reque # Make sure the logs are written to stderr stderr_mock.write.assert_called_with(lambda_logs) + + @patch('samcli.local.lambda_service.local_lambda_invoke_service.LambdaErrorResponses') + def test_construct_error_handling(self, lambda_error_response_mock): + service = LocalLambdaInvokeService(lambda_runner=Mock(), + port=3000, + host='localhost', + stderr=Mock()) + + flask_app_mock = Mock() + service._app = flask_app_mock + service._construct_error_handling() + + flask_app_mock.register_error_handler.assert_has_calls([ + call(500, lambda_error_response_mock.generic_service_exception), + call(404, lambda_error_response_mock.generic_path_not_found), + call(405, lambda_error_response_mock.generic_method_not_allowed)]) + + @patch('samcli.local.lambda_service.local_lambda_invoke_service.LocalLambdaInvokeService.service_response') + @patch('samcli.local.lambda_service.local_lambda_invoke_service.LambdaOutputParser') + @patch('samcli.local.lambda_service.local_lambda_invoke_service.request') + def test_invoke_request_handler_with_lambda_that_errors(self, + request_mock, + lambda_output_parser_mock, + service_response_mock): + lambda_output_parser_mock.get_lambda_output.return_value = 'hello world', None, True + service_response_mock.return_value = 'request response' + request_mock.get_data.return_value = b'{}' + + lambda_runner_mock = Mock() + service = LocalLambdaInvokeService(lambda_runner=lambda_runner_mock, port=3000, host='localhost') + + response = service._invoke_request_handler(function_name='HelloWorld') + + self.assertEquals(response, 'request response') + + lambda_runner_mock.invoke.assert_called_once_with('HelloWorld', '{}', stdout=ANY, stderr=None) + service_response_mock.assert_called_once_with('hello world', + {'Content-Type': 'application/json', + 'x-amz-function-error': 'Unhandled'}, + 200) + + @patch('samcli.local.lambda_service.local_lambda_invoke_service.LocalLambdaInvokeService.service_response') + @patch('samcli.local.lambda_service.local_lambda_invoke_service.LambdaOutputParser') + @patch('samcli.local.lambda_service.local_lambda_invoke_service.request') + def test_invoke_request_handler_with_no_data(self, request_mock, lambda_output_parser_mock, service_response_mock): + lambda_output_parser_mock.get_lambda_output.return_value = 'hello world', None, False + service_response_mock.return_value = 'request response' + request_mock.get_data.return_value = None + + lambda_runner_mock = Mock() + service = LocalLambdaInvokeService(lambda_runner=lambda_runner_mock, port=3000, host='localhost') + + response = service._invoke_request_handler(function_name='HelloWorld') + + self.assertEquals(response, 'request response') + + lambda_runner_mock.invoke.assert_called_once_with('HelloWorld', '{}', stdout=ANY, stderr=None) + service_response_mock.assert_called_once_with('hello world', {'Content-Type': 'application/json'}, 200) + + +class TestValidateRequestHandling(TestCase): + + @patch('samcli.local.lambda_service.local_lambda_invoke_service.LambdaErrorResponses') + @patch('samcli.local.lambda_service.local_lambda_invoke_service.request') + def test_request_with_non_json_data(self, flask_request, lambda_error_responses_mock): + flask_request.get_data.return_value = b'notat:asdfasdf' + flask_request.headers = {} + flask_request.content_type = 'application/json' + flask_request.args = {} + + lambda_error_responses_mock.invalid_request_content.return_value = "InvalidRequestContent" + + response = LocalLambdaInvokeService.validate_request() + + self.assertEquals(response, "InvalidRequestContent") + + if sys.version_info.major == 2: + expected_called_with = "Could not parse request body into json: No JSON object could be decoded" + else: + expected_called_with = "Could not parse request body into json: Expecting value: line 1 column 1 (char 0)" + + lambda_error_responses_mock.invalid_request_content.assert_called_once_with(expected_called_with) + + @patch('samcli.local.lambda_service.local_lambda_invoke_service.LambdaErrorResponses') + @patch('samcli.local.lambda_service.local_lambda_invoke_service.request') + def test_request_with_query_strings(self, flask_request, lambda_error_responses_mock): + flask_request.get_data.return_value = None + flask_request.headers = {} + flask_request.content_type = 'application/json' + flask_request.args = {"key": "value"} + + lambda_error_responses_mock.invalid_request_content.return_value = "InvalidRequestContent" + + response = LocalLambdaInvokeService.validate_request() + + self.assertEquals(response, "InvalidRequestContent") + + lambda_error_responses_mock.invalid_request_content.assert_called_once_with( + "Query Parameters are not supported") + + @patch('samcli.local.lambda_service.local_lambda_invoke_service.LambdaErrorResponses') + @patch('samcli.local.lambda_service.local_lambda_invoke_service.request') + def test_request_with_content_type_not_application_json(self, flask_request, lambda_error_responses_mock): + flask_request.get_data.return_value = None + flask_request.headers = {} + flask_request.content_type = 'image/gif' + flask_request.args = {} + + lambda_error_responses_mock.unsupported_media_type.return_value = "UnsupportedMediaType" + + response = LocalLambdaInvokeService.validate_request() + + self.assertEquals(response, "UnsupportedMediaType") + + lambda_error_responses_mock.unsupported_media_type.assert_called_once_with( + "image/gif") + + @patch('samcli.local.lambda_service.local_lambda_invoke_service.LambdaErrorResponses') + @patch('samcli.local.lambda_service.local_lambda_invoke_service.request') + def test_request_log_type_not_None(self, flask_request, lambda_error_responses_mock): + flask_request.get_data.return_value = None + flask_request.headers = {'X-Amz-Log-Type': 'Tail'} + flask_request.content_type = 'application/json' + flask_request.args = {} + + lambda_error_responses_mock.not_implemented_locally.return_value = "NotImplementedLocally" + + response = LocalLambdaInvokeService.validate_request() + + self.assertEquals(response, "NotImplementedLocally") + + lambda_error_responses_mock.not_implemented_locally.assert_called_once_with( + "log-type: Tail is not supported. None is only supported.") + + @patch('samcli.local.lambda_service.local_lambda_invoke_service.LambdaErrorResponses') + @patch('samcli.local.lambda_service.local_lambda_invoke_service.request') + def test_request_invocation_type_not_ResponseRequest(self, flask_request, lambda_error_responses_mock): + flask_request.get_data.return_value = None + flask_request.headers = {'X-Amz-Invocation-Type': 'DryRun'} + flask_request.content_type = 'application/json' + flask_request.args = {} + + lambda_error_responses_mock.not_implemented_locally.return_value = "NotImplementedLocally" + + response = LocalLambdaInvokeService.validate_request() + + self.assertEquals(response, "NotImplementedLocally") + + lambda_error_responses_mock.not_implemented_locally.assert_called_once_with( + "invocation-type: DryRun is not supported. RequestResponse is only supported.") + + @patch('samcli.local.lambda_service.local_lambda_invoke_service.request') + def test_request_with_no_data(self, flask_request): + flask_request.get_data.return_value = None + flask_request.headers = {} + flask_request.content_type = 'application/json' + flask_request.args = {} + + response = LocalLambdaInvokeService.validate_request() + + self.assertIsNone(response) diff --git a/tests/unit/local/services/test_base_local_service.py b/tests/unit/local/services/test_base_local_service.py index 428b3f3f2746..711780acc464 100644 --- a/tests/unit/local/services/test_base_local_service.py +++ b/tests/unit/local/services/test_base_local_service.py @@ -3,7 +3,7 @@ from parameterized import parameterized, param -from samcli.local.services.base_local_service import BaseLocalService, LambdaOutputParser +from samcli.local.services.base_local_service import BaseLocalService, LambdaOutputParser, CaseInsensitiveDict class TestLocalHostRunner(TestCase): @@ -49,7 +49,7 @@ def test_service_response(self, flask_response_patch): status_code = 200 headers = {"Content-Type": "application/json"} - actual_response = BaseLocalService._service_response(body, headers, status_code) + actual_response = BaseLocalService.service_response(body, headers, status_code) flask_response_patch.assert_called_once_with("this is the body") @@ -69,41 +69,91 @@ class TestLambdaOutputParser(TestCase): @parameterized.expand([ param( "with both logs and response", - b'this\nis\nlog\ndata\n{"a": "b"}', b'this\nis\nlog\ndata', b'{"a": "b"}' + b'this\nis\nlog\ndata\n{"a": "b"}', b'this\nis\nlog\ndata', '{"a": "b"}' ), param( "with response as string", - b"logs\nresponse", b"logs", b"response" + b"logs\nresponse", b"logs", "response" ), param( "with response only", - b'{"a": "b"}', None, b'{"a": "b"}' + b'{"a": "b"}', None, '{"a": "b"}' ), param( "with response only as string", - b'this is the response line', None, b'this is the response line' + b'this is the response line', None, 'this is the response line' ), param( "with whitespaces", - b'log\ndata\n{"a": "b"} \n\n\n', b"log\ndata", b'{"a": "b"}' + b'log\ndata\n{"a": "b"} \n\n\n', b"log\ndata", '{"a": "b"}' ), param( "with empty data", - b'', None, b'' + b'', None, '' ), param( "with just new lines", - b'\n\n', None, b'' + b'\n\n', None, '' ), param( "with no data but with whitespaces", - b'\n \n \n', b'\n ', b'' # Log data with whitespaces will be in the output unchanged + b'\n \n \n', b'\n ', '' # Log data with whitespaces will be in the output unchanged ) ]) def test_get_lambda_output_extracts_response(self, test_case_name, stdout_data, expected_logs, expected_response): stdout = Mock() stdout.getvalue.return_value = stdout_data - response, logs = LambdaOutputParser.get_lambda_output(stdout) + response, logs, is_customer_error = LambdaOutputParser.get_lambda_output(stdout) self.assertEquals(logs, expected_logs) self.assertEquals(response, expected_response) + self.assertFalse(is_customer_error) + + @parameterized.expand([ + param('{"errorMessage": "has a message", "stackTrace": "has a stacktrace", "errorType": "has a type"}', + True), + param('{"error message": "has a message", "stack Trace": "has a stacktrace", "error Type": "has a type"}', + False), + param('{"errorMessage": "has a message", "stackTrace": "has a stacktrace", "errorType": "has a type", ' + '"hasextrakey": "value"}', + False), + param("notat:asdfasdf", + False), + param("errorMessage and stackTrace and errorType are in the string", + False) + ]) + def test_is_lambda_error_response(self, input, exected_result): + self.assertEquals(LambdaOutputParser.is_lambda_error_response(input), exected_result) + + +class CaseInsensiveDict(TestCase): + + def setUp(self): + self.data = CaseInsensitiveDict({ + 'Content-Type': 'text/html', + 'Browser': 'APIGW', + }) + + def test_contains_lower(self): + self.assertTrue('content-type' in self.data) + + def test_contains_title(self): + self.assertTrue('Content-Type' in self.data) + + def test_contains_upper(self): + self.assertTrue('CONTENT-TYPE' in self.data) + + def test_contains_browser_key(self): + self.assertTrue('Browser' in self.data) + + def test_contains_not_in(self): + self.assertTrue('Dog-Food' not in self.data) + + def test_setitem_found(self): + self.data['Browser'] = 'APIGW' + + self.assertTrue(self.data['browser']) + + def test_keyerror(self): + with self.assertRaises(KeyError): + self.data['does-not-exist']