From bd09c29cbdf10fd15e45653966774dc05cb5e189 Mon Sep 17 00:00:00 2001 From: Jacob Fuss <32497805+jfuss@users.noreply.github.com> Date: Tue, 3 Jul 2018 20:01:03 -0700 Subject: [PATCH] feat(LocalLambdaService): Initial implementation to Invoke Local Lambdas through a Local Lambda Service (#508) --- .../commands/local/lib/local_api_service.py | 14 +- .../{service.py => local_apigw_service.py} | 101 +-------- samcli/local/lambda_service/__init__.py | 0 .../local_lambda_invoke_service.py | 90 ++++++++ samcli/local/services/__init__.py | 0 samcli/local/services/base_local_service.py | 121 +++++++++++ ...service.py => test_local_apigw_service.py} | 6 +- .../local/lambda_service/__init__.py | 0 .../test_local_lambda_invoke.py | 126 ++++++++++++ .../local/lib/test_local_api_service.py | 6 +- ...service.py => test_local_apigw_service.py} | 194 +++++------------- tests/unit/local/lambda_service/__init__.py | 0 .../test_local_lambda_invoke_service.py | 102 +++++++++ tests/unit/local/services/__init__.py | 0 .../local/services/test_base_local_service.py | 109 ++++++++++ 15 files changed, 627 insertions(+), 242 deletions(-) rename samcli/local/apigw/{service.py => local_apigw_service.py} (77%) create mode 100644 samcli/local/lambda_service/__init__.py create mode 100644 samcli/local/lambda_service/local_lambda_invoke_service.py create mode 100644 samcli/local/services/__init__.py create mode 100644 samcli/local/services/base_local_service.py rename tests/functional/local/apigw/{test_service.py => test_local_apigw_service.py} (99%) create mode 100644 tests/functional/local/lambda_service/__init__.py create mode 100644 tests/functional/local/lambda_service/test_local_lambda_invoke.py rename tests/unit/local/apigw/{test_service.py => test_local_apigw_service.py} (74%) create mode 100644 tests/unit/local/lambda_service/__init__.py create mode 100644 tests/unit/local/lambda_service/test_local_lambda_invoke_service.py create mode 100644 tests/unit/local/services/__init__.py create mode 100644 tests/unit/local/services/test_base_local_service.py diff --git a/samcli/commands/local/lib/local_api_service.py b/samcli/commands/local/lib/local_api_service.py index e8761798de..e0243192c8 100644 --- a/samcli/commands/local/lib/local_api_service.py +++ b/samcli/commands/local/lib/local_api_service.py @@ -5,7 +5,7 @@ import os import logging -from samcli.local.apigw.service import Service, Route +from samcli.local.apigw.local_apigw_service import LocalApigwService, Route from samcli.commands.local.lib.sam_api_provider import SamApiProvider from samcli.commands.local.lib.exceptions import NoApisDefined @@ -62,12 +62,12 @@ def start(self): # contains the response to the API which is sent out as HTTP response. Only stderr needs to be printed # to the console or a log file. stderr from Docker container contains runtime logs and output of print # statements from the Lambda function - service = Service(routing_list=routing_list, - lambda_runner=self.lambda_runner, - static_dir=static_dir_path, - port=self.port, - host=self.host, - stderr=self.stderr_stream) + service = LocalApigwService(routing_list=routing_list, + lambda_runner=self.lambda_runner, + static_dir=static_dir_path, + port=self.port, + host=self.host, + stderr=self.stderr_stream) service.create() diff --git a/samcli/local/apigw/service.py b/samcli/local/apigw/local_apigw_service.py similarity index 77% rename from samcli/local/apigw/service.py rename to samcli/local/apigw/local_apigw_service.py index 6441708367..ec4a01c942 100644 --- a/samcli/local/apigw/service.py +++ b/samcli/local/apigw/local_apigw_service.py @@ -2,11 +2,11 @@ import io import json import logging -import os import base64 -from flask import Flask, request, Response +from flask import Flask, request +from samcli.local.services.base_local_service import BaseLocalService, LambdaOutputParser from samcli.local.lambdafn.exceptions import FunctionNotFound from samcli.local.events.api_event import ContextIdentity, RequestContext, ApiGatewayLambdaEvent from .service_error_responses import ServiceErrorResponses @@ -48,7 +48,7 @@ def __init__(self, methods, function_name, path, binary_types=None): self.binary_types = binary_types or [] -class Service(object): +class LocalApigwService(BaseLocalService): _DEFAULT_PORT = 3000 _DEFAULT_HOST = '127.0.0.1' @@ -65,16 +65,14 @@ def __init__(self, routing_list, lambda_runner, static_dir=None, port=None, host :param int port: Optional. port for the service to start listening on Defaults to 3000 :param str host: Optional. host to start the service on - Defaults to '0.0.0.0' + Defaults to '127.0.0.1 :param io.BaseIO stderr: Optional stream where the stderr from Docker container should be written to """ + super(LocalApigwService, self).__init__(lambda_runner.is_debugging(), port=port, host=host) self.routing_list = routing_list self.lambda_runner = lambda_runner self.static_dir = static_dir - self.port = port or self._DEFAULT_PORT - self.host = host or self._DEFAULT_HOST self._dict_of_routes = {} - self._app = None self.stderr = stderr def create(self): @@ -128,32 +126,6 @@ def _construct_error_handling(self): # Something went wrong self._app.register_error_handler(500, ServiceErrorResponses.lambda_failure_response) - def run(self): - """ - This starts up the (threaded) Local Server. - Note: This is a **blocking call** - - :raise RuntimeError: If the service was not created - """ - - if not self._app: - raise RuntimeError("The application must be created before running") - - # Flask can operate as a single threaded server (which is default) and a multi-threaded server which is - # more for development. When the Lambda container is going to be debugged, then it does not make sense - # to turn on multi-threading because customers can realistically attach only one container at a time to - # the debugger. Keeping this single threaded also enables the Lambda Runner to handle Ctrl+C in order to - # kill the container gracefully (Ctrl+C can be handled only by the main thread) - multi_threaded = not self.lambda_runner.is_debugging() - - LOG.debug("Local API Server starting up. Multi-threading = %s", multi_threaded) - - # This environ signifies we are running a main function for Flask. This is true, since we are using it within - # our cli and not on a production server. - os.environ['WERKZEUG_RUN_MAIN'] = 'true' - - self._app.run(threaded=multi_threaded, host=self.host, port=self.port) - def _request_handler(self, **kwargs): """ We handle all requests to the host:port. The general flow of handling a request is as follows @@ -186,7 +158,7 @@ def _request_handler(self, **kwargs): except FunctionNotFound: return ServiceErrorResponses.lambda_not_found_response() - lambda_response, lambda_logs = self._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. @@ -203,21 +175,6 @@ def _request_handler(self, **kwargs): return self._service_response(body, headers, status_code) - @staticmethod - def _service_response(body, headers, status_code): - """ - Constructs a Flask Response from the body, headers, and status_code. - - :param str body: Response body as a string - :param dict headers: headers for the response - :param int status_code: status_code for response - :return: Flask Response - """ - response = Response(body) - response.headers = headers - response.status_code = status_code - return response - def _get_current_route(self, flask_request): """ Get the route (Route) based on the current request @@ -239,46 +196,6 @@ def _get_current_route(self, flask_request): return route - @staticmethod - def _get_lambda_output(stdout_stream): - """ - This method will extract read the given stream and return the response from Lambda function separated out - from any log statements it might have outputted. Logs end up in the stdout stream if the Lambda function - wrote directly to stdout using System.out.println or equivalents. - - Parameters - ---------- - stdout_stream : io.BaseIO - Stream to fetch data from - - Returns - ------- - str - String data containing response from Lambda function - str - String data containng logs statements, if any. - """ - # We only want the last line of stdout, because it's possible that - # the function may have written directly to stdout using - # System.out.println or similar, before docker-lambda output the result - stdout_data = stdout_stream.getvalue().rstrip(b'\n') - - # Usually the output is just one line and contains response as JSON string, but if the Lambda function - # wrote anything directly to stdout, there will be additional lines. So just extract the last line as - # response and everything else as log output. - lambda_response = stdout_data - lambda_logs = None - - last_line_position = stdout_data.rfind(b'\n') - if last_line_position > 0: - # So there are multiple lines. Separate them out. - # Everything but the last line are logs - lambda_logs = stdout_data[:last_line_position] - # 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 - # Consider moving this out to its own class. Logic is started to get dense and looks messy @jfuss @staticmethod def _parse_lambda_output(lambda_output, binary_types, flask_request): @@ -308,7 +225,7 @@ def _parse_lambda_output(lambda_output, binary_types, flask_request): LOG.info("No Content-Type given. Defaulting to 'application/json'.") headers["Content-Type"] = "application/json" - if Service._should_base64_decode_body(binary_types, flask_request, headers, is_base_64_encoded): + if LocalApigwService._should_base64_decode_body(binary_types, flask_request, headers, is_base_64_encoded): body = base64.b64decode(body) return status_code, headers, body @@ -357,7 +274,7 @@ def _construct_event(flask_request, port, binary_types): request_mimetype = flask_request.mimetype - is_base_64 = Service._should_base64_encode(binary_types, request_mimetype) + is_base_64 = LocalApigwService._should_base64_encode(binary_types, request_mimetype) if is_base_64: LOG.debug("Incoming Request seems to be binary. Base64 encoding the request data before sending to Lambda.") @@ -380,7 +297,7 @@ def _construct_event(flask_request, port, binary_types): # APIGW does not support duplicate query parameters. Flask gives query params as a list so # we need to convert only grab the first item unless many were given, were we grab the last to be consistent # with APIGW - query_string_dict = Service._query_string_params(flask_request) + query_string_dict = LocalApigwService._query_string_params(flask_request) event = ApiGatewayLambdaEvent(http_method=method, body=request_data, diff --git a/samcli/local/lambda_service/__init__.py b/samcli/local/lambda_service/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/samcli/local/lambda_service/local_lambda_invoke_service.py b/samcli/local/lambda_service/local_lambda_invoke_service.py new file mode 100644 index 0000000000..4b65ec60e7 --- /dev/null +++ b/samcli/local/lambda_service/local_lambda_invoke_service.py @@ -0,0 +1,90 @@ +"""Local Lambda Service that only invokes a function""" + +import logging +import io + +from flask import Flask, request + + +from samcli.local.services.base_local_service import BaseLocalService, LambdaOutputParser +from samcli.local.lambdafn.exceptions import FunctionNotFound + +LOG = logging.getLogger(__name__) + + +class LocalLambdaInvokeService(BaseLocalService): + + def __init__(self, lambda_runner, port, host, stderr=None): + """ + Creates a Local Lambda Service that will only response to invoking a function + + Parameters + ---------- + lambda_runner samcli.commands.local.lib.local_lambda.LocalLambdaRunner + The Lambda runner class capable of invoking the function + port int + Optional. port for the service to start listening on + host str + Optional. host to start the service on + stderr io.BaseIO + Optional stream where the stderr from Docker container should be written to + """ + super(LocalLambdaInvokeService, self).__init__(lambda_runner.is_debugging(), port=port, host=host) + self.lambda_runner = lambda_runner + self.stderr = stderr + + def create(self): + """ + Creates a Flask Application that can be started. + """ + self._app = Flask(__name__) + + path = '/2015-03-31/functions//invocations' + self._app.add_url_rule(path, + endpoint=path, + view_func=self._invoke_request_handler, + methods=['POST'], + provide_automatic_options=False) + + self._construct_error_handling() + + def _construct_error_handling(self): + """ + Updates the Flask app with Error Handlers for different Error Codes + + """ + pass + + def _invoke_request_handler(self, function_name): + """ + Request Handler for the Local Lambda Invoke path. This method is responsible for understanding the incoming + request and invoking the Local Lambda Function + + Parameters + ---------- + function_name str + Name of the function to invoke + + 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') + + 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') + + lambda_response, lambda_logs = 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) diff --git a/samcli/local/services/__init__.py b/samcli/local/services/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/samcli/local/services/base_local_service.py b/samcli/local/services/base_local_service.py new file mode 100644 index 0000000000..9a2874bc7f --- /dev/null +++ b/samcli/local/services/base_local_service.py @@ -0,0 +1,121 @@ +"""Base class for all Services that interact with Local Lambda""" + +import logging +import os + +from flask import Response + +LOG = logging.getLogger(__name__) + + +class BaseLocalService(object): + + def __init__(self, is_debugging, port, host): + """ + Creates a BaseLocalService class + + Parameters + ---------- + is_debugging bool + Flag to run in debug mode or not + port int + Optional. port for the service to start listening on Defaults to 3000 + host str + Optional. host to start the service on Defaults to '127.0.0.1 + """ + self.is_debugging = is_debugging + self.port = port + self.host = host + self._app = None + + def create(self): + """ + Creates a Flask Application that can be started. + """ + raise NotImplementedError("Required method to implement") + + def run(self): + """ + This starts up the (threaded) Local Server. + Note: This is a **blocking call** + + Raises + ------ + RuntimeError + if the service was not created + """ + if not self._app: + raise RuntimeError("The application must be created before running") + + # Flask can operate as a single threaded server (which is default) and a multi-threaded server which is + # more for development. When the Lambda container is going to be debugged, then it does not make sense + # to turn on multi-threading because customers can realistically attach only one container at a time to + # the debugger. Keeping this single threaded also enables the Lambda Runner to handle Ctrl+C in order to + # kill the container gracefully (Ctrl+C can be handled only by the main thread) + multi_threaded = not self.is_debugging + + LOG.debug("Localhost server is starting up. Multi-threading = %s", multi_threaded) + + # This environ signifies we are running a main function for Flask. This is true, since we are using it within + # our cli and not on a production server. + os.environ['WERKZEUG_RUN_MAIN'] = 'true' + + self._app.run(threaded=multi_threaded, host=self.host, port=self.port) + + @staticmethod + def _service_response(body, headers, status_code): + """ + Constructs a Flask Response from the body, headers, and status_code. + + :param str body: Response body as a string + :param dict headers: headers for the response + :param int status_code: status_code for response + :return: Flask Response + """ + response = Response(body) + response.headers = headers + response.status_code = status_code + return response + + +class LambdaOutputParser(object): + + @staticmethod + def get_lambda_output(stdout_stream): + """ + This method will extract read the given stream and return the response from Lambda function separated out + from any log statements it might have outputted. Logs end up in the stdout stream if the Lambda function + wrote directly to stdout using System.out.println or equivalents. + + Parameters + ---------- + stdout_stream : io.BaseIO + Stream to fetch data from + + Returns + ------- + str + String data containing response from Lambda function + str + String data containng logs statements, if any. + """ + # We only want the last line of stdout, because it's possible that + # the function may have written directly to stdout using + # System.out.println or similar, before docker-lambda output the result + stdout_data = stdout_stream.getvalue().rstrip(b'\n') + + # Usually the output is just one line and contains response as JSON string, but if the Lambda function + # wrote anything directly to stdout, there will be additional lines. So just extract the last line as + # response and everything else as log output. + lambda_response = stdout_data + lambda_logs = None + + last_line_position = stdout_data.rfind(b'\n') + if last_line_position > 0: + # So there are multiple lines. Separate them out. + # Everything but the last line are logs + lambda_logs = stdout_data[:last_line_position] + # 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 diff --git a/tests/functional/local/apigw/test_service.py b/tests/functional/local/apigw/test_local_apigw_service.py similarity index 99% rename from tests/functional/local/apigw/test_service.py rename to tests/functional/local/apigw/test_local_apigw_service.py index 8cf6b851d6..7ae8fa8939 100644 --- a/tests/functional/local/apigw/test_service.py +++ b/tests/functional/local/apigw/test_local_apigw_service.py @@ -4,13 +4,12 @@ import shutil import json import time -import logging import requests import random from mock import Mock -from samcli.local.apigw.service import Route, Service +from samcli.local.apigw.local_apigw_service import Route, LocalApigwService from tests.functional.function_code import nodejs_lambda, API_GATEWAY_ECHO_EVENT, API_GATEWAY_BAD_PROXY_RESPONSE, API_GATEWAY_ECHO_BASE64_EVENT, API_GATEWAY_CONTENT_TYPE_LOWER from samcli.commands.local.lib import provider from samcli.local.lambdafn.runtime import LambdaRuntime @@ -550,6 +549,7 @@ def test_post_binary_with_incorrect_content_type(self): self.assertEquals(response.status_code, 502) self.assertEquals(response.headers.get('Content-Type'), "application/json") + class TestService_FlaskDefaultOptionsDisabled(TestCase): @classmethod def setUpClass(cls): @@ -617,7 +617,7 @@ def make_service(list_of_routes, function_provider, cwd): function_provider=function_provider, cwd=cwd) - service = Service(list_of_routes, lambda_runner, port=port) + service = LocalApigwService(list_of_routes, lambda_runner, port=port) scheme = "http" url = '{}://0.0.0.0:{}'.format(scheme, port) diff --git a/tests/functional/local/lambda_service/__init__.py b/tests/functional/local/lambda_service/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/functional/local/lambda_service/test_local_lambda_invoke.py b/tests/functional/local/lambda_service/test_local_lambda_invoke.py new file mode 100644 index 0000000000..e6dc0e8277 --- /dev/null +++ b/tests/functional/local/lambda_service/test_local_lambda_invoke.py @@ -0,0 +1,126 @@ +import threading +import shutil +import random +from mock import Mock +import time +from unittest import TestCase +import os + +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 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 + + +class TestLocalLambdaService(TestCase): + @classmethod + def setUpClass(cls): + 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.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 + + 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.create() + 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_mock_response_is_returned(self): + expected = 'Hello from Lambda' + + 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 TestLocalEchoLambdaService(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 + + 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.create() + 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_mock_response_is_returned(self): + expected = {"key1": "value1"} + + response = requests.post(self.url + '/2015-03-31/functions/HelloWorld/invocations', json={"key1": "value1"}) + + actual = response.json() + + self.assertEquals(actual, expected) + self.assertEquals(response.status_code, 200) + + +def make_service(list_of_function_names, function_provider, cwd): + port = random_port() + manager = ContainerManager() + local_runtime = LambdaRuntime(manager) + lambda_runner = LocalLambdaRunner(local_runtime=local_runtime, + function_provider=function_provider, + cwd=cwd) + + service = LocalLambdaInvokeService(lambda_runner, port=port, host='127.0.0.1') + + scheme = "http" + url = '{}://127.0.0.1:{}'.format(scheme, port) + return service, port, url, scheme + +def random_port(): + return random.randint(30000, 40000) \ No newline at end of file diff --git a/tests/unit/commands/local/lib/test_local_api_service.py b/tests/unit/commands/local/lib/test_local_api_service.py index aee6f7f4fa..d018ad0525 100644 --- a/tests/unit/commands/local/lib/test_local_api_service.py +++ b/tests/unit/commands/local/lib/test_local_api_service.py @@ -8,7 +8,7 @@ from samcli.commands.local.lib.local_api_service import LocalApiService from samcli.commands.local.lib.exceptions import NoApisDefined from samcli.commands.local.lib.provider import Api -from samcli.local.apigw.service import Route +from samcli.local.apigw.local_apigw_service import Route class TestLocalApiService_start(TestCase): @@ -32,7 +32,7 @@ def setUp(self): self.lambda_invoke_context_mock.get_cwd.return_value = self.cwd self.lambda_invoke_context_mock.stderr = self.stderr_mock - @patch("samcli.commands.local.lib.local_api_service.Service") + @patch("samcli.commands.local.lib.local_api_service.LocalApigwService") @patch("samcli.commands.local.lib.local_api_service.SamApiProvider") @patch.object(LocalApiService, "_make_static_dir_path") @patch.object(LocalApiService, "_print_routes") @@ -73,7 +73,7 @@ def test_must_start_service(self, self.apigw_service.create.assert_called_with() self.apigw_service.run.assert_called_with() - @patch("samcli.commands.local.lib.local_api_service.Service") + @patch("samcli.commands.local.lib.local_api_service.LocalApigwService") @patch("samcli.commands.local.lib.local_api_service.SamApiProvider") @patch.object(LocalApiService, "_make_static_dir_path") @patch.object(LocalApiService, "_print_routes") diff --git a/tests/unit/local/apigw/test_service.py b/tests/unit/local/apigw/test_local_apigw_service.py similarity index 74% rename from tests/unit/local/apigw/test_service.py rename to tests/unit/local/apigw/test_local_apigw_service.py index 8fd5b16aa3..3e741e21dc 100644 --- a/tests/unit/local/apigw/test_service.py +++ b/tests/unit/local/apigw/test_local_apigw_service.py @@ -1,11 +1,11 @@ from unittest import TestCase -from mock import Mock, patch, MagicMock, ANY +from mock import Mock, patch, ANY import json import base64 from parameterized import parameterized, param -from samcli.local.apigw.service import Service, Route, CaseInsensitiveDict +from samcli.local.apigw.local_apigw_service import LocalApigwService, Route, CaseInsensitiveDict from samcli.local.lambdafn.exceptions import FunctionNotFound @@ -17,9 +17,14 @@ def setUp(self): self.list_of_routes = [self.api_gateway_route] self.lambda_runner = Mock() + self.lambda_runner.is_debugging.return_value = False self.stderr = Mock() - self.service = Service(self.list_of_routes, self.lambda_runner, stderr=self.stderr) + self.service = LocalApigwService(self.list_of_routes, + self.lambda_runner, + port=3000, + host='127.0.0.1', + stderr=self.stderr) def test_request_must_invoke_lambda(self): make_response_mock = Mock() @@ -44,7 +49,9 @@ def test_request_must_invoke_lambda(self): stdout=ANY, stderr=self.stderr) - def test_request_handler_returns_process_stdout_when_making_response(self): + @patch('samcli.local.apigw.local_apigw_service.LambdaOutputParser') + def test_request_handler_returns_process_stdout_when_making_response(self, lambda_output_parser_mock): + make_response_mock = Mock() self.service._service_response = make_response_mock @@ -57,8 +64,7 @@ def test_request_handler_returns_process_stdout_when_making_response(self): lambda_logs = "logs" lambda_response = "response" - self.service._get_lambda_output = Mock() - self.service._get_lambda_output.return_value = lambda_response, lambda_logs + lambda_output_parser_mock.get_lambda_output.return_value = lambda_response, lambda_logs service_response_mock = Mock() service_response_mock.return_value = make_response_mock @@ -67,7 +73,7 @@ def test_request_handler_returns_process_stdout_when_making_response(self): result = self.service._request_handler() self.assertEquals(result, make_response_mock) - self.service._get_lambda_output.assert_called_with(ANY) + lambda_output_parser_mock.get_lambda_output.assert_called_with(ANY) # Make sure the parse method is called only on the returned response and not on the raw data from stdout parse_output_mock.assert_called_with(lambda_response, ANY, ANY) @@ -93,30 +99,6 @@ def test_request_handler_returns_make_response(self): self.assertEquals(result, make_response_mock) - def test_runtime_error_raised_when_app_not_created(self): - with self.assertRaises(RuntimeError): - self.service.run() - - def test_run_starts_service_multithreaded(self): - self.service._app = Mock() - app_run_mock = Mock() - self.service._app.run = app_run_mock - - self.lambda_runner.is_debugging.return_value = False # multithreaded - self.service.run() - - app_run_mock.assert_called_once_with(threaded=True, host='127.0.0.1', port=3000) - - def test_run_starts_service_singlethreaded(self): - self.service._app = Mock() - app_run_mock = Mock() - self.service._app.run = app_run_mock - - self.lambda_runner.is_debugging.return_value = True # single threaded - self.service.run() - - app_run_mock.assert_called_once_with(threaded=False, host='127.0.0.1', port=3000) - def test_create_creates_dict_of_routes(self): function_name_1 = Mock() function_name_2 = Mock() @@ -127,7 +109,7 @@ def test_create_creates_dict_of_routes(self): lambda_runner = Mock() - service = Service(list_of_routes, lambda_runner) + service = LocalApigwService(list_of_routes, lambda_runner) service.create() @@ -135,7 +117,7 @@ def test_create_creates_dict_of_routes(self): '/:POST': api_gateway_route_2 }) - @patch('samcli.local.apigw.service.Flask') + @patch('samcli.local.apigw.local_apigw_service.Flask') def test_create_creates_flask_app_with_url_rules(self, flask): app_mock = Mock() flask.return_value = app_mock @@ -159,14 +141,14 @@ def test_initalize_creates_default_values(self): def test_initalize_with_values(self): lambda_runner = Mock() - local_service = Service([], lambda_runner, static_dir='dir/static', port=5000, host='129.0.0.0') + local_service = LocalApigwService([], lambda_runner, static_dir='dir/static', port=5000, host='129.0.0.0') self.assertEquals(local_service.port, 5000) self.assertEquals(local_service.host, '129.0.0.0') self.assertEquals(local_service.routing_list, []) self.assertEquals(local_service.static_dir, 'dir/static') self.assertEquals(local_service.lambda_runner, lambda_runner) - @patch('samcli.local.apigw.service.ServiceErrorResponses') + @patch('samcli.local.apigw.local_apigw_service.ServiceErrorResponses') def test_request_handles_error_when_invoke_cant_find_function(self, service_error_responses_patch): not_found_response_mock = Mock() @@ -189,7 +171,7 @@ def test_request_throws_when_invoke_fails(self): with self.assertRaises(Exception): self.service._request_handler() - @patch('samcli.local.apigw.service.ServiceErrorResponses') + @patch('samcli.local.apigw.local_apigw_service.ServiceErrorResponses') def test_request_handler_errors_when_parse_lambda_output_raises_keyerror(self, service_error_responses_patch): parse_output_mock = Mock() parse_output_mock.side_effect = KeyError() @@ -206,7 +188,7 @@ def test_request_handler_errors_when_parse_lambda_output_raises_keyerror(self, s self.assertEquals(result, failure_response_mock) - @patch('samcli.local.apigw.service.ServiceErrorResponses') + @patch('samcli.local.apigw.local_apigw_service.ServiceErrorResponses') def test_request_handler_errors_when_get_current_route_fails(self, service_error_responses_patch): get_current_route = Mock() get_current_route.side_effect = KeyError() @@ -215,7 +197,7 @@ def test_request_handler_errors_when_get_current_route_fails(self, service_error with self.assertRaises(KeyError): self.service._request_handler() - @patch('samcli.local.apigw.service.ServiceErrorResponses') + @patch('samcli.local.apigw.local_apigw_service.ServiceErrorResponses') def test_request_handler_errors_when_unable_to_read_binary_data(self, service_error_responses_patch): _construct_event = Mock() _construct_event.side_effect = UnicodeDecodeError("utf8", b"obj", 1, 2, "reason") @@ -228,7 +210,7 @@ def test_request_handler_errors_when_unable_to_read_binary_data(self, service_er result = self.service._request_handler() self.assertEquals(result, failure_mock) - @patch('samcli.local.apigw.service.request') + @patch('samcli.local.apigw.local_apigw_service.request') def test_get_current_route(self, request_patch): request_mock = Mock() @@ -244,7 +226,7 @@ def test_get_current_route(self, request_patch): self.assertEquals(self.service._get_current_route(request_mock), "function") - @patch('samcli.local.apigw.service.request') + @patch('samcli.local.apigw.local_apigw_service.request') def test_get_current_route_keyerror(self, request_patch): """ When the a HTTP request for given method+path combination is allowed by Flask but not in the list of routes, @@ -265,48 +247,6 @@ def test_get_current_route_keyerror(self, request_patch): with self.assertRaises(KeyError): self.service._get_current_route(request_mock) - @parameterized.expand([ - param( - "with both logs and response", - b'this\nis\nlog\ndata\n{"a": "b"}', b'this\nis\nlog\ndata', b'{"a": "b"}' - ), - param( - "with response as string", - b"logs\nresponse", b"logs", b"response" - ), - param( - "with response only", - b'{"a": "b"}', None, b'{"a": "b"}' - ), - param( - "with response only as string", - b'this is the response line', None, b'this is the response line' - ), - param( - "with whitespaces", - b'log\ndata\n{"a": "b"} \n\n\n', b"log\ndata", b'{"a": "b"}' - ), - param( - "with empty data", - b'', None, b'' - ), - param( - "with just new lines", - b'\n\n', None, b'' - ), - param( - "with no data but with whitespaces", - b'\n \n \n', b'\n ', b'' # 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 = self.service._get_lambda_output(stdout) - self.assertEquals(logs, expected_logs) - self.assertEquals(response, expected_response) - class TestApiGatewayModel(TestCase): @@ -326,7 +266,7 @@ def test_default_content_type_header_added_with_no_headers(self): lambda_output = '{"statusCode": 200, "body": "{\\"message\\":\\"Hello from Lambda\\"}", ' \ '"isBase64Encoded": false}' - (_, headers, _) = Service._parse_lambda_output(lambda_output, binary_types=[], flask_request=Mock()) + (_, headers, _) = LocalApigwService._parse_lambda_output(lambda_output, binary_types=[], flask_request=Mock()) self.assertIn("Content-Type", headers) self.assertEquals(headers["Content-Type"], "application/json") @@ -335,7 +275,7 @@ def test_default_content_type_header_added_with_empty_headers(self): lambda_output = '{"statusCode": 200, "headers":{}, "body": "{\\"message\\":\\"Hello from Lambda\\"}", ' \ '"isBase64Encoded": false}' - (_, headers, _) = Service._parse_lambda_output(lambda_output, binary_types=[], flask_request=Mock()) + (_, headers, _) = LocalApigwService._parse_lambda_output(lambda_output, binary_types=[], flask_request=Mock()) self.assertIn("Content-Type", headers) self.assertEquals(headers["Content-Type"], "application/json") @@ -344,7 +284,7 @@ def test_custom_content_type_header_is_not_modified(self): lambda_output = '{"statusCode": 200, "headers":{"Content-Type": "text/xml"}, "body": "{}", ' \ '"isBase64Encoded": false}' - (_, headers, _) = Service._parse_lambda_output(lambda_output, binary_types=[], flask_request=Mock()) + (_, headers, _) = LocalApigwService._parse_lambda_output(lambda_output, binary_types=[], flask_request=Mock()) self.assertIn("Content-Type", headers) self.assertEquals(headers["Content-Type"], "text/xml") @@ -353,9 +293,9 @@ def test_extra_values_ignored(self): lambda_output = '{"statusCode": 200, "headers": {}, "body": "{\\"message\\":\\"Hello from Lambda\\"}", ' \ '"isBase64Encoded": false, "another_key": "some value"}' - (status_code, headers, body) = Service._parse_lambda_output(lambda_output, - binary_types=[], - flask_request=Mock()) + (status_code, headers, body) = LocalApigwService._parse_lambda_output(lambda_output, + binary_types=[], + flask_request=Mock()) self.assertEquals(status_code, 200) self.assertEquals(headers, {"Content-Type": "application/json"}) @@ -365,15 +305,15 @@ def test_parse_returns_correct_tuple(self): lambda_output = '{"statusCode": 200, "headers": {}, "body": "{\\"message\\":\\"Hello from Lambda\\"}", ' \ '"isBase64Encoded": false}' - (status_code, headers, body) = Service._parse_lambda_output(lambda_output, - binary_types=[], - flask_request=Mock()) + (status_code, headers, body) = LocalApigwService._parse_lambda_output(lambda_output, + binary_types=[], + flask_request=Mock()) self.assertEquals(status_code, 200) self.assertEquals(headers, {"Content-Type": "application/json"}) self.assertEquals(body, '{"message":"Hello from Lambda"}') - @patch('samcli.local.apigw.service.Service._should_base64_decode_body') + @patch('samcli.local.apigw.local_apigw_service.LocalApigwService._should_base64_decode_body') def test_parse_returns_decodes_base64_to_binary(self, should_decode_body_patch): should_decode_body_patch.return_value = True @@ -384,9 +324,9 @@ def test_parse_returns_decodes_base64_to_binary(self, should_decode_body_patch): "body": base64_body, "isBase64Encoded": False} - (status_code, headers, body) = Service._parse_lambda_output(json.dumps(lambda_output), - binary_types=['*/*'], - flask_request=Mock()) + (status_code, headers, body) = LocalApigwService._parse_lambda_output(json.dumps(lambda_output), + binary_types=['*/*'], + flask_request=Mock()) self.assertEquals(status_code, 200) self.assertEquals(headers, {"Content-Type": "application/octet-stream"}) @@ -397,40 +337,40 @@ def test_status_code_not_int(self): '"isBase64Encoded": false}' with self.assertRaises(TypeError): - Service._parse_lambda_output(lambda_output, - binary_types=[], - flask_request=Mock()) + LocalApigwService._parse_lambda_output(lambda_output, + binary_types=[], + flask_request=Mock()) def test_status_code_negative_int(self): lambda_output = '{"statusCode": -1, "headers": {}, "body": "{\\"message\\":\\"Hello from Lambda\\"}", ' \ '"isBase64Encoded": false}' with self.assertRaises(TypeError): - Service._parse_lambda_output(lambda_output, - binary_types=[], - flask_request=Mock()) + LocalApigwService._parse_lambda_output(lambda_output, + binary_types=[], + flask_request=Mock()) def test_lambda_output_list_not_dict(self): lambda_output = '[]' with self.assertRaises(TypeError): - Service._parse_lambda_output(lambda_output, - binary_types=[], - flask_request=Mock()) + LocalApigwService._parse_lambda_output(lambda_output, + binary_types=[], + flask_request=Mock()) def test_lambda_output_not_json_serializable(self): lambda_output = 'some str' with self.assertRaises(ValueError): - Service._parse_lambda_output(lambda_output, binary_types=[], flask_request=Mock()) + LocalApigwService._parse_lambda_output(lambda_output, binary_types=[], flask_request=Mock()) def test_properties_are_null(self): lambda_output = '{"statusCode": 0, "headers": null, "body": null, ' \ '"isBase64Encoded": null}' - (status_code, headers, body) = Service._parse_lambda_output(lambda_output, - binary_types=[], - flask_request=Mock()) + (status_code, headers, body) = LocalApigwService._parse_lambda_output(lambda_output, + binary_types=[], + flask_request=Mock()) self.assertEquals(status_code, 200) self.assertEquals(headers, {"Content-Type": "application/json"}) @@ -467,17 +407,17 @@ def setUp(self): self.expected_dict = json.loads(expected) def test_construct_event_with_data(self): - actual_event_str = Service._construct_event(self.request_mock, 3000, binary_types=[]) + actual_event_str = LocalApigwService._construct_event(self.request_mock, 3000, binary_types=[]) self.assertEquals(json.loads(actual_event_str), self.expected_dict) def test_construct_event_no_data(self): self.request_mock.get_data.return_value = None self.expected_dict["body"] = None - actual_event_str = Service._construct_event(self.request_mock, 3000, binary_types=[]) + actual_event_str = LocalApigwService._construct_event(self.request_mock, 3000, binary_types=[]) self.assertEquals(json.loads(actual_event_str), self.expected_dict) - @patch('samcli.local.apigw.service.Service._should_base64_encode') + @patch('samcli.local.apigw.local_apigw_service.LocalApigwService._should_base64_encode') def test_construct_event_with_binary_data(self, should_base64_encode_patch): should_base64_encode_patch.return_value = True @@ -488,51 +428,31 @@ def test_construct_event_with_binary_data(self, should_base64_encode_patch): self.expected_dict["body"] = base64_body self.expected_dict["isBase64Encoded"] = True - actual_event_str = Service._construct_event(self.request_mock, 3000, binary_types=[]) + actual_event_str = LocalApigwService._construct_event(self.request_mock, 3000, binary_types=[]) self.assertEquals(json.loads(actual_event_str), self.expected_dict) def test_query_string_params_with_empty_params(self): request_mock = Mock() request_mock.args = {} - actual_query_string = Service._query_string_params(request_mock) + actual_query_string = LocalApigwService._query_string_params(request_mock) self.assertEquals(actual_query_string, {}) def test_query_string_params_with_param_value_being_empty_list(self): request_mock = Mock() request_mock.args = {"param": []} - actual_query_string = Service._query_string_params(request_mock) + actual_query_string = LocalApigwService._query_string_params(request_mock) self.assertEquals(actual_query_string, {"param": ""}) def test_query_string_params_with_param_value_being_non_empty_list(self): request_mock = Mock() request_mock.args = {"param": ["a", "b"]} - actual_query_string = Service._query_string_params(request_mock) + actual_query_string = LocalApigwService._query_string_params(request_mock) self.assertEquals(actual_query_string, {"param": "b"}) -class TestService_service_response(TestCase): - - @patch('samcli.local.apigw.service.Response') - def test_service_response(self, flask_response_patch): - flask_response_mock = MagicMock() - - flask_response_patch.return_value = flask_response_mock - - body = "this is the body" - status_code = 200 - headers = {"Content-Type": "application/json"} - - actual_response = Service._service_response(body, headers, status_code) - - flask_response_patch.assert_called_once_with("this is the body") - - self.assertEquals(actual_response.status_code, 200) - self.assertEquals(actual_response.headers, {"Content-Type": "application/json"}) - - class TestService_should_base64_encode(TestCase): @parameterized.expand([ @@ -541,13 +461,13 @@ class TestService_should_base64_encode(TestCase): param("*/* is in binary types with no mimetype defined", ['*/*'], None) ]) def test_should_base64_encode_returns_true(self, test_case_name, binary_types, mimetype): - self.assertTrue(Service._should_base64_encode(binary_types, mimetype)) + self.assertTrue(LocalApigwService._should_base64_encode(binary_types, mimetype)) @parameterized.expand([ param("Mimetype is not in binary types", ['image/gif'], "application/octet-stream") ]) def test_should_base64_encode_returns_false(self, test_case_name, binary_types, mimetype): - self.assertFalse(Service._should_base64_encode(binary_types, mimetype)) + self.assertFalse(LocalApigwService._should_base64_encode(binary_types, mimetype)) class TestService_CaseInsensiveDict(TestCase): diff --git a/tests/unit/local/lambda_service/__init__.py b/tests/unit/local/lambda_service/__init__.py new file mode 100644 index 0000000000..e69de29bb2 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 new file mode 100644 index 0000000000..dca78c5c25 --- /dev/null +++ b/tests/unit/local/lambda_service/test_local_lambda_invoke_service.py @@ -0,0 +1,102 @@ +from unittest import TestCase +from mock import Mock, patch, ANY + +from samcli.local.lambda_service.local_lambda_invoke_service import LocalLambdaInvokeService +from samcli.local.lambdafn.exceptions import FunctionNotFound + + +class TestLocalLambdaService(TestCase): + + def test_initalize_creates_default_values(self): + lambda_runner_mock = Mock() + service = LocalLambdaInvokeService(lambda_runner=lambda_runner_mock, port=3001, host='127.0.0.1') + self.assertEquals(service.port, 3001) + self.assertEquals(service.host, '127.0.0.1') + self.assertEquals(service.lambda_runner, lambda_runner_mock) + self.assertIsNone(service.stderr) + + def test_initalize_with_values(self): + lambda_runner_mock = Mock() + stderr_mock = Mock() + local_service = LocalLambdaInvokeService(lambda_runner_mock, port=5000, host='129.0.0.0', stderr=stderr_mock) + self.assertEquals(local_service.port, 5000) + self.assertEquals(local_service.host, '129.0.0.0') + self.assertEquals(local_service.stderr, stderr_mock) + self.assertEquals(local_service.lambda_runner, lambda_runner_mock) + + @patch('samcli.local.lambda_service.local_lambda_invoke_service.LocalLambdaInvokeService._construct_error_handling') + @patch('samcli.local.lambda_service.local_lambda_invoke_service.Flask') + def test_create_service_endpoints(self, flask_mock, error_handling_mock): + app_mock = Mock() + flask_mock.return_value = app_mock + + error_handling_mock.return_value = Mock() + + lambda_runner_mock = Mock() + service = LocalLambdaInvokeService(lambda_runner=lambda_runner_mock, port=3000, host='localhost') + + service.create() + + app_mock.add_url_rule.assert_called_once_with('/2015-03-31/functions//invocations', + endpoint='/2015-03-31/functions//invocations', + view_func=service._invoke_request_handler, + 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.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 + 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'}, 200) + + @patch('samcli.local.lambda_service.local_lambda_invoke_service.request') + def test_invoke_request_handler_on_incorrect_path(self, request_mock): + request_mock.get_data.return_value = b'{}' + lambda_runner_mock = Mock() + lambda_runner_mock.invoke.side_effect = FunctionNotFound + service = LocalLambdaInvokeService(lambda_runner=lambda_runner_mock, port=3000, host='localhost') + + with self.assertRaises(Exception): + service._invoke_request_handler(function_name='NotFound') + + 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') + @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, + service_response_mock): + request_mock.get_data.return_value = b'{}' + + lambda_logs = "logs" + lambda_response = "response" + lambda_output_parser_mock.get_lambda_output.return_value = lambda_response, lambda_logs + + service_response_mock.return_value = 'request response' + + lambda_runner_mock = Mock() + stderr_mock = Mock() + service = LocalLambdaInvokeService(lambda_runner=lambda_runner_mock, + port=3000, + host='localhost', + stderr=stderr_mock) + + result = service._invoke_request_handler(function_name='HelloWorld') + + self.assertEquals(result, 'request response') + lambda_output_parser_mock.get_lambda_output.assert_called_with(ANY) + + # Make sure the logs are written to stderr + stderr_mock.write.assert_called_with(lambda_logs) diff --git a/tests/unit/local/services/__init__.py b/tests/unit/local/services/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/local/services/test_base_local_service.py b/tests/unit/local/services/test_base_local_service.py new file mode 100644 index 0000000000..428b3f3f27 --- /dev/null +++ b/tests/unit/local/services/test_base_local_service.py @@ -0,0 +1,109 @@ +from unittest import TestCase +from mock import Mock, patch + +from parameterized import parameterized, param + +from samcli.local.services.base_local_service import BaseLocalService, LambdaOutputParser + + +class TestLocalHostRunner(TestCase): + + def test_runtime_error_raised_when_app_not_created(self): + is_debugging = False + service = BaseLocalService(is_debugging=is_debugging, port=3000, host='127.0.0.1') + + with self.assertRaises(RuntimeError): + service.run() + + def test_run_starts_service_multithreaded(self): + is_debugging = False # multithreaded + service = BaseLocalService(is_debugging=is_debugging, port=3000, host='127.0.0.1') + + service._app = Mock() + app_run_mock = Mock() + service._app.run = app_run_mock + + service.run() + + app_run_mock.assert_called_once_with(threaded=True, host='127.0.0.1', port=3000) + + def test_run_starts_service_singlethreaded(self): + is_debugging = True # singlethreaded + service = BaseLocalService(is_debugging=is_debugging, port=3000, host='127.0.0.1') + + service._app = Mock() + app_run_mock = Mock() + service._app.run = app_run_mock + + service.run() + + app_run_mock.assert_called_once_with(threaded=False, host='127.0.0.1', port=3000) + + @patch('samcli.local.services.base_local_service.Response') + def test_service_response(self, flask_response_patch): + flask_response_mock = Mock() + + flask_response_patch.return_value = flask_response_mock + + body = "this is the body" + status_code = 200 + headers = {"Content-Type": "application/json"} + + actual_response = BaseLocalService._service_response(body, headers, status_code) + + flask_response_patch.assert_called_once_with("this is the body") + + self.assertEquals(actual_response.status_code, 200) + self.assertEquals(actual_response.headers, {"Content-Type": "application/json"}) + + def test_create_returns_not_implemented(self): + is_debugging = False + service = BaseLocalService(is_debugging=is_debugging, port=3000, host='127.0.0.1') + + with self.assertRaises(NotImplementedError): + service.create() + + +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"}' + ), + param( + "with response as string", + b"logs\nresponse", b"logs", b"response" + ), + param( + "with response only", + b'{"a": "b"}', None, b'{"a": "b"}' + ), + param( + "with response only as string", + b'this is the response line', None, b'this is the response line' + ), + param( + "with whitespaces", + b'log\ndata\n{"a": "b"} \n\n\n', b"log\ndata", b'{"a": "b"}' + ), + param( + "with empty data", + b'', None, b'' + ), + param( + "with just new lines", + b'\n\n', None, b'' + ), + param( + "with no data but with whitespaces", + b'\n \n \n', b'\n ', b'' # 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) + self.assertEquals(logs, expected_logs) + self.assertEquals(response, expected_response)