Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat(LocalLambdaService): Invoke Local Lambdas through a Local Lambda Service #508

Merged
merged 6 commits into from
Jul 4, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions samcli/commands/local/lib/local_api_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.")
Expand All @@ -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,
Expand Down
Empty file.
90 changes: 90 additions & 0 deletions samcli/local/lambda_service/local_lambda_invoke_service.py
Original file line number Diff line number Diff line change
@@ -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/<function_name>/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)
Empty file.
Loading