Skip to content

Commit

Permalink
Merge pull request #3 from amancevice/v2
Browse files Browse the repository at this point in the history
V2
  • Loading branch information
amancevice authored Dec 16, 2020
2 parents 11074ad + d8e9c78 commit 8299469
Show file tree
Hide file tree
Showing 12 changed files with 432 additions and 100 deletions.
3 changes: 2 additions & 1 deletion .github/workflows/pytest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ on:
pull_request:
push:
schedule:
- cron: "11 21 * * *"
- cron: '11 21 * * *'
jobs:
pytest:
runs-on: ubuntu-latest
Expand All @@ -12,6 +12,7 @@ jobs:
python:
- 3.7
- 3.8
- 3.9
steps:
- uses: actions/checkout@v1
- uses: actions/setup-python@v1
Expand Down
9 changes: 6 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,17 @@ default: $(SDIST)
clean:
rm -rf dist

test:
py.test
test: coverage.xml

upload: $(SDIST)
twine upload $<

up:
SLEEP=$(SLEEP) python -m lambda_gateway -t $(TIMEOUT) lambda_function.lambda_handler

$(SDIST): test
coverage.xml: $(shell find . -name '*.py' -not -path './.*')
flake8 $^
pytest

$(SDIST): coverage.xml
python setup.py sdist
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,13 @@ The timeout length can be adjusted using the `-t / --timeout` CLI option.
```bash
lambda-gateway -t 3 lambda_function.lambda_handler
```

## API Gateway Payloads

API Gateway supports [two versions](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html) of proxied JSON payloads to Lambda integrations, `1.0` and `2.0`.

Versions `0.8+` of Lambda Gateway use `2.0` by default, but this can be changed at startup time using the `-V / --payload-version` option:

```bash
lambda-gateway -V1.0 lambda_function.lambda_handler
```
6 changes: 6 additions & 0 deletions lambda_gateway/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import logging
import pkg_resources

try:
__version__ = pkg_resources.get_distribution(__package__).version
except pkg_resources.DistributionNotFound: # pragma: no cover
__version__ = None


def set_stream_logger(name, level=logging.DEBUG, format_string=None):
Expand Down
36 changes: 28 additions & 8 deletions lambda_gateway/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,14 @@
from lambda_gateway.event_proxy import EventProxy
from lambda_gateway.request_handler import LambdaRequestHandler

from lambda_gateway import __version__


def get_best_family(*address): # pragma: no cover
""" Helper for Python 3.7 compat.
"""
Helper for Python 3.7 compat.
:params tuple address: host/port tuple
:params tuple address: host/port tuple
"""
# Python 3.8+
try:
Expand All @@ -32,7 +35,9 @@ def get_best_family(*address): # pragma: no cover


def get_opts():
""" Get CLI options. """
"""
Get CLI options.
"""
parser = argparse.ArgumentParser(
description='Start a simple Lambda Gateway server',
)
Expand Down Expand Up @@ -62,6 +67,18 @@ def get_opts():
metavar='SECONDS',
type=int,
)
parser.add_argument(
'-v', '--version',
action='version',
help='Print version and exit',
version=f'%(prog)s {__version__}',
)
parser.add_argument(
'-V', '--payload-version',
choices=['1.0', '2.0'],
default='2.0',
help='API Gateway payload version [default: 2.0]',
)
parser.add_argument(
'HANDLER',
help='Lambda handler signature',
Expand All @@ -70,10 +87,11 @@ def get_opts():


def run(httpd, base_path='/'):
""" Run Lambda Gateway server.
"""
Run Lambda Gateway server.
:param object httpd: ThreadingHTTPServer instance
:param str base_path: REST API base path
:param object httpd: ThreadingHTTPServer instance
:param str base_path: REST API base path
"""
host, port = httpd.socket.getsockname()[:2]
url_host = f'[{host}]' if ':' in host else host
Expand All @@ -90,7 +108,9 @@ def run(httpd, base_path='/'):


def main():
""" Main entrypoint. """
"""
Main entrypoint.
"""
# Parse opts
opts = get_opts()

Expand All @@ -100,7 +120,7 @@ def main():
# Setup handler
address_family, addr = get_best_family(opts.bind, opts.port)
proxy = EventProxy(opts.HANDLER, base_path, opts.timeout)
LambdaRequestHandler.set_proxy(proxy)
LambdaRequestHandler.set_proxy(proxy, opts.payload_version)
server.ThreadingHTTPServer.address_family = address_family

# Start server
Expand Down
60 changes: 43 additions & 17 deletions lambda_gateway/event_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ def __init__(self, handler, base_path, timeout=None):
self.timeout = timeout

def get_handler(self):
""" Load handler function.
"""
Load handler function.
:returns function: Lambda handler function
:returns function: Lambda handler function
"""
*path, func = self.handler.split('.')
name = '.'.join(path)
Expand All @@ -32,20 +33,43 @@ def get_handler(self):
except AttributeError:
raise ValueError(f"Handler '{func}' missing on module '{name}'")

def get_httpMethod(self, event):
"""
Helper to get httpMethod from v1 or v2 events.
"""
if event.get('version') == '2.0':
return event['requestContext']['http']['method']
elif event.get('version') == '1.0':
return event['httpMethod']
raise ValueError( # pragma: no cover
f"Unknown API Gateway payload version: {event.get('version')}")

def get_path(self, event):
"""
Helper to get path from v1 or v2 events.
"""
if event.get('version') == '2.0':
return event['rawPath']
elif event.get('version') == '1.0':
return event['path']
raise ValueError( # pragma: no cover
f"Unknown API Gateway payload version: {event.get('version')}")

def invoke(self, event):
with lambda_context.start(self.timeout) as context:
logger.info('Invoking "%s"', self.handler)
return asyncio.run(self.invoke_async_with_timeout(event, context))

async def invoke_async(self, event, context=None):
""" Wrapper to invoke the Lambda handler asynchronously.
"""
Wrapper to invoke the Lambda handler asynchronously.
:param dict event: Lambda event object
:param Context context: Mock Lambda context
:returns dict: Lamnda invocation result
:param dict event: Lambda event object
:param Context context: Mock Lambda context
:returns dict: Lamnda invocation result
"""
httpMethod = event['httpMethod']
path = event['path']
httpMethod = self.get_httpMethod(event)
path = self.get_path(event)

# Reject request if not starting at base path
if not path.startswith(self.base_path):
Expand All @@ -64,27 +88,29 @@ async def invoke_async(self, event, context=None):
return self.jsonify(httpMethod, 502, message=message)

async def invoke_async_with_timeout(self, event, context=None):
""" Wrapper to invoke the Lambda handler with a timeout.
"""
Wrapper to invoke the Lambda handler with a timeout.
:param dict event: Lambda event object
:param Context context: Mock Lambda context
:returns dict: Lamnda invocation result or 408 TIMEOUT
:param dict event: Lambda event object
:param Context context: Mock Lambda context
:returns dict: Lamnda invocation result or 408 TIMEOUT
"""
try:
coroutine = self.invoke_async(event, context)
return await asyncio.wait_for(coroutine, self.timeout)
except asyncio.TimeoutError:
httpMethod = event['httpMethod']
httpMethod = self.get_httpMethod(event)
message = 'Endpoint request timed out'
return self.jsonify(httpMethod, 504, message=message)

@staticmethod
def jsonify(httpMethod, statusCode, **kwargs):
""" Convert dict into API Gateway response object.
"""
Convert dict into API Gateway response object.
:params str httpMethod: HTTP request method
:params int statusCode: Response status code
:params dict kwargs: Response object
:params str httpMethod: HTTP request method
:params int statusCode: Response status code
:params dict kwargs: Response object
"""
body = '' if httpMethod in ['HEAD'] else json.dumps(kwargs)
return {
Expand Down
12 changes: 9 additions & 3 deletions lambda_gateway/lambda_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@

@contextmanager
def start(timeout=None):
""" Yield mock Lambda context object. """
"""
Yield mock Lambda context object.
"""
yield Context(timeout)


class Context:
""" Mock Lambda context object.
"""
Mock Lambda context object.
:param int timeout: Lambda timeout in seconds
:param int timeout: Lambda timeout in seconds
"""
def __init__(self, timeout=None):
self._start = datetime.utcnow()
Expand Down Expand Up @@ -50,6 +53,9 @@ def log_stream_name(self):
return str(uuid.uuid1())

def get_remaining_time_in_millis(self):
"""
Get remaining TTL for Lambda context.
"""
delta = datetime.utcnow() - self._start
remaining_time_in_s = self._timeout - delta.total_seconds()
if remaining_time_in_s < 0:
Expand Down
69 changes: 59 additions & 10 deletions lambda_gateway/request_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,34 +13,79 @@ def do_POST(self):
self.invoke('POST')

def get_body(self):
""" Get request body to forward to Lambda handler. """
"""
Get request body to forward to Lambda handler.
"""
try:
content_length = int(self.headers.get('Content-Length'))
return self.rfile.read(content_length).decode()
except TypeError:
return ''

def get_event(self, httpMethod):
""" Get Lambda input event object.
"""
Get Lambda input event object.
:param str httpMethod: HTTP request method
:return dict: Lambda event object
"""
if self.version == '1.0':
return self.get_event_v1(httpMethod)
elif self.version == '2.0':
return self.get_event_v2(httpMethod)
raise ValueError( # pragma: no cover
f'Unknown API Gateway payload version: {self.version}')

:param str httpMethod: HTTP request method
:return dict: Lambda event object
def get_event_v1(self, httpMethod):
"""
Get Lambda input event object (v1).
:param str httpMethod: HTTP request method
:return dict: Lambda event object
"""
url = parse.urlparse(self.path)
path, *_ = url.path.split('?')
return {
'version': '1.0',
'body': self.get_body(),
'headers': dict(self.headers),
'httpMethod': httpMethod,
'path': url.path,
'path': path,
'queryStringParameters': dict(parse.parse_qsl(url.query)),
}

def get_event_v2(self, httpMethod):
"""
Get Lambda input event object (v2).
:param str httpMethod: HTTP request method
:return dict: Lambda event object
"""
url = parse.urlparse(self.path)
path, *_ = url.path.split('?')
return {
'version': '2.0',
'body': self.get_body(),
'routeKey': f'{httpMethod} {path}',
'rawPath': path,
'rawQueryString': url.query,
'headers': dict(self.headers),
'queryStringParameters': dict(parse.parse_qsl(url.query)),
'requestContext': {
'http': {
'method': httpMethod,
'path': path,
},
},
}

def invoke(self, httpMethod):
""" Proxy requests to Lambda handler
"""
Proxy requests to Lambda handler
:param dict event: Lambda event object
:param Context context: Mock Lambda context
:returns dict: Lamnda invocation result
:param dict event: Lambda event object
:param Context context: Mock Lambda context
:returns dict: Lamnda invocation result
"""
# Get Lambda event
event = self.get_event(httpMethod)
Expand All @@ -61,5 +106,9 @@ def invoke(self, httpMethod):
self.wfile.write(body.encode())

@classmethod
def set_proxy(cls, proxy):
def set_proxy(cls, proxy, version):
"""
Set up LambdaRequestHandler.
"""
cls.proxy = proxy
cls.version = version
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta:__legacy__"
4 changes: 2 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from setuptools import find_packages
from setuptools import setup
from setuptools import (find_packages, setup)

with open('README.md', 'r') as readme:
long_description = readme.read()
Expand All @@ -17,6 +16,7 @@
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9',
'Topic :: Utilities',
],
description='Simple HTTP server to invoke a Lambda function locally',
Expand Down
Loading

0 comments on commit 8299469

Please sign in to comment.