Skip to content

Commit 8299469

Browse files
authored
Merge pull request #3 from amancevice/v2
V2
2 parents 11074ad + d8e9c78 commit 8299469

File tree

12 files changed

+432
-100
lines changed

12 files changed

+432
-100
lines changed

.github/workflows/pytest.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ on:
33
pull_request:
44
push:
55
schedule:
6-
- cron: "11 21 * * *"
6+
- cron: '11 21 * * *'
77
jobs:
88
pytest:
99
runs-on: ubuntu-latest
@@ -12,6 +12,7 @@ jobs:
1212
python:
1313
- 3.7
1414
- 3.8
15+
- 3.9
1516
steps:
1617
- uses: actions/checkout@v1
1718
- uses: actions/setup-python@v1

Makefile

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,17 @@ default: $(SDIST)
99
clean:
1010
rm -rf dist
1111

12-
test:
13-
py.test
12+
test: coverage.xml
1413

1514
upload: $(SDIST)
1615
twine upload $<
1716

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

21-
$(SDIST): test
20+
coverage.xml: $(shell find . -name '*.py' -not -path './.*')
21+
flake8 $^
22+
pytest
23+
24+
$(SDIST): coverage.xml
2225
python setup.py sdist

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,13 @@ The timeout length can be adjusted using the `-t / --timeout` CLI option.
7171
```bash
7272
lambda-gateway -t 3 lambda_function.lambda_handler
7373
```
74+
75+
## API Gateway Payloads
76+
77+
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`.
78+
79+
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:
80+
81+
```bash
82+
lambda-gateway -V1.0 lambda_function.lambda_handler
83+
```

lambda_gateway/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
import logging
2+
import pkg_resources
3+
4+
try:
5+
__version__ = pkg_resources.get_distribution(__package__).version
6+
except pkg_resources.DistributionNotFound: # pragma: no cover
7+
__version__ = None
28

39

410
def set_stream_logger(name, level=logging.DEBUG, format_string=None):

lambda_gateway/__main__.py

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,14 @@
1010
from lambda_gateway.event_proxy import EventProxy
1111
from lambda_gateway.request_handler import LambdaRequestHandler
1212

13+
from lambda_gateway import __version__
14+
1315

1416
def get_best_family(*address): # pragma: no cover
15-
""" Helper for Python 3.7 compat.
17+
"""
18+
Helper for Python 3.7 compat.
1619
17-
:params tuple address: host/port tuple
20+
:params tuple address: host/port tuple
1821
"""
1922
# Python 3.8+
2023
try:
@@ -32,7 +35,9 @@ def get_best_family(*address): # pragma: no cover
3235

3336

3437
def get_opts():
35-
""" Get CLI options. """
38+
"""
39+
Get CLI options.
40+
"""
3641
parser = argparse.ArgumentParser(
3742
description='Start a simple Lambda Gateway server',
3843
)
@@ -62,6 +67,18 @@ def get_opts():
6267
metavar='SECONDS',
6368
type=int,
6469
)
70+
parser.add_argument(
71+
'-v', '--version',
72+
action='version',
73+
help='Print version and exit',
74+
version=f'%(prog)s {__version__}',
75+
)
76+
parser.add_argument(
77+
'-V', '--payload-version',
78+
choices=['1.0', '2.0'],
79+
default='2.0',
80+
help='API Gateway payload version [default: 2.0]',
81+
)
6582
parser.add_argument(
6683
'HANDLER',
6784
help='Lambda handler signature',
@@ -70,10 +87,11 @@ def get_opts():
7087

7188

7289
def run(httpd, base_path='/'):
73-
""" Run Lambda Gateway server.
90+
"""
91+
Run Lambda Gateway server.
7492
75-
:param object httpd: ThreadingHTTPServer instance
76-
:param str base_path: REST API base path
93+
:param object httpd: ThreadingHTTPServer instance
94+
:param str base_path: REST API base path
7795
"""
7896
host, port = httpd.socket.getsockname()[:2]
7997
url_host = f'[{host}]' if ':' in host else host
@@ -90,7 +108,9 @@ def run(httpd, base_path='/'):
90108

91109

92110
def main():
93-
""" Main entrypoint. """
111+
"""
112+
Main entrypoint.
113+
"""
94114
# Parse opts
95115
opts = get_opts()
96116

@@ -100,7 +120,7 @@ def main():
100120
# Setup handler
101121
address_family, addr = get_best_family(opts.bind, opts.port)
102122
proxy = EventProxy(opts.HANDLER, base_path, opts.timeout)
103-
LambdaRequestHandler.set_proxy(proxy)
123+
LambdaRequestHandler.set_proxy(proxy, opts.payload_version)
104124
server.ThreadingHTTPServer.address_family = address_family
105125

106126
# Start server

lambda_gateway/event_proxy.py

Lines changed: 43 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@ def __init__(self, handler, base_path, timeout=None):
1313
self.timeout = timeout
1414

1515
def get_handler(self):
16-
""" Load handler function.
16+
"""
17+
Load handler function.
1718
18-
:returns function: Lambda handler function
19+
:returns function: Lambda handler function
1920
"""
2021
*path, func = self.handler.split('.')
2122
name = '.'.join(path)
@@ -32,20 +33,43 @@ def get_handler(self):
3233
except AttributeError:
3334
raise ValueError(f"Handler '{func}' missing on module '{name}'")
3435

36+
def get_httpMethod(self, event):
37+
"""
38+
Helper to get httpMethod from v1 or v2 events.
39+
"""
40+
if event.get('version') == '2.0':
41+
return event['requestContext']['http']['method']
42+
elif event.get('version') == '1.0':
43+
return event['httpMethod']
44+
raise ValueError( # pragma: no cover
45+
f"Unknown API Gateway payload version: {event.get('version')}")
46+
47+
def get_path(self, event):
48+
"""
49+
Helper to get path from v1 or v2 events.
50+
"""
51+
if event.get('version') == '2.0':
52+
return event['rawPath']
53+
elif event.get('version') == '1.0':
54+
return event['path']
55+
raise ValueError( # pragma: no cover
56+
f"Unknown API Gateway payload version: {event.get('version')}")
57+
3558
def invoke(self, event):
3659
with lambda_context.start(self.timeout) as context:
3760
logger.info('Invoking "%s"', self.handler)
3861
return asyncio.run(self.invoke_async_with_timeout(event, context))
3962

4063
async def invoke_async(self, event, context=None):
41-
""" Wrapper to invoke the Lambda handler asynchronously.
64+
"""
65+
Wrapper to invoke the Lambda handler asynchronously.
4266
43-
:param dict event: Lambda event object
44-
:param Context context: Mock Lambda context
45-
:returns dict: Lamnda invocation result
67+
:param dict event: Lambda event object
68+
:param Context context: Mock Lambda context
69+
:returns dict: Lamnda invocation result
4670
"""
47-
httpMethod = event['httpMethod']
48-
path = event['path']
71+
httpMethod = self.get_httpMethod(event)
72+
path = self.get_path(event)
4973

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

6690
async def invoke_async_with_timeout(self, event, context=None):
67-
""" Wrapper to invoke the Lambda handler with a timeout.
91+
"""
92+
Wrapper to invoke the Lambda handler with a timeout.
6893
69-
:param dict event: Lambda event object
70-
:param Context context: Mock Lambda context
71-
:returns dict: Lamnda invocation result or 408 TIMEOUT
94+
:param dict event: Lambda event object
95+
:param Context context: Mock Lambda context
96+
:returns dict: Lamnda invocation result or 408 TIMEOUT
7297
"""
7398
try:
7499
coroutine = self.invoke_async(event, context)
75100
return await asyncio.wait_for(coroutine, self.timeout)
76101
except asyncio.TimeoutError:
77-
httpMethod = event['httpMethod']
102+
httpMethod = self.get_httpMethod(event)
78103
message = 'Endpoint request timed out'
79104
return self.jsonify(httpMethod, 504, message=message)
80105

81106
@staticmethod
82107
def jsonify(httpMethod, statusCode, **kwargs):
83-
""" Convert dict into API Gateway response object.
108+
"""
109+
Convert dict into API Gateway response object.
84110
85-
:params str httpMethod: HTTP request method
86-
:params int statusCode: Response status code
87-
:params dict kwargs: Response object
111+
:params str httpMethod: HTTP request method
112+
:params int statusCode: Response status code
113+
:params dict kwargs: Response object
88114
"""
89115
body = '' if httpMethod in ['HEAD'] else json.dumps(kwargs)
90116
return {

lambda_gateway/lambda_context.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,17 @@
55

66
@contextmanager
77
def start(timeout=None):
8-
""" Yield mock Lambda context object. """
8+
"""
9+
Yield mock Lambda context object.
10+
"""
911
yield Context(timeout)
1012

1113

1214
class Context:
13-
""" Mock Lambda context object.
15+
"""
16+
Mock Lambda context object.
1417
15-
:param int timeout: Lambda timeout in seconds
18+
:param int timeout: Lambda timeout in seconds
1619
"""
1720
def __init__(self, timeout=None):
1821
self._start = datetime.utcnow()
@@ -50,6 +53,9 @@ def log_stream_name(self):
5053
return str(uuid.uuid1())
5154

5255
def get_remaining_time_in_millis(self):
56+
"""
57+
Get remaining TTL for Lambda context.
58+
"""
5359
delta = datetime.utcnow() - self._start
5460
remaining_time_in_s = self._timeout - delta.total_seconds()
5561
if remaining_time_in_s < 0:

lambda_gateway/request_handler.py

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,34 +13,79 @@ def do_POST(self):
1313
self.invoke('POST')
1414

1515
def get_body(self):
16-
""" Get request body to forward to Lambda handler. """
16+
"""
17+
Get request body to forward to Lambda handler.
18+
"""
1719
try:
1820
content_length = int(self.headers.get('Content-Length'))
1921
return self.rfile.read(content_length).decode()
2022
except TypeError:
2123
return ''
2224

2325
def get_event(self, httpMethod):
24-
""" Get Lambda input event object.
26+
"""
27+
Get Lambda input event object.
28+
29+
:param str httpMethod: HTTP request method
30+
:return dict: Lambda event object
31+
"""
32+
if self.version == '1.0':
33+
return self.get_event_v1(httpMethod)
34+
elif self.version == '2.0':
35+
return self.get_event_v2(httpMethod)
36+
raise ValueError( # pragma: no cover
37+
f'Unknown API Gateway payload version: {self.version}')
2538

26-
:param str httpMethod: HTTP request method
27-
:return dict: Lambda event object
39+
def get_event_v1(self, httpMethod):
40+
"""
41+
Get Lambda input event object (v1).
42+
43+
:param str httpMethod: HTTP request method
44+
:return dict: Lambda event object
2845
"""
2946
url = parse.urlparse(self.path)
47+
path, *_ = url.path.split('?')
3048
return {
49+
'version': '1.0',
3150
'body': self.get_body(),
3251
'headers': dict(self.headers),
3352
'httpMethod': httpMethod,
34-
'path': url.path,
53+
'path': path,
54+
'queryStringParameters': dict(parse.parse_qsl(url.query)),
55+
}
56+
57+
def get_event_v2(self, httpMethod):
58+
"""
59+
Get Lambda input event object (v2).
60+
61+
:param str httpMethod: HTTP request method
62+
:return dict: Lambda event object
63+
"""
64+
url = parse.urlparse(self.path)
65+
path, *_ = url.path.split('?')
66+
return {
67+
'version': '2.0',
68+
'body': self.get_body(),
69+
'routeKey': f'{httpMethod} {path}',
70+
'rawPath': path,
71+
'rawQueryString': url.query,
72+
'headers': dict(self.headers),
3573
'queryStringParameters': dict(parse.parse_qsl(url.query)),
74+
'requestContext': {
75+
'http': {
76+
'method': httpMethod,
77+
'path': path,
78+
},
79+
},
3680
}
3781

3882
def invoke(self, httpMethod):
39-
""" Proxy requests to Lambda handler
83+
"""
84+
Proxy requests to Lambda handler
4085
41-
:param dict event: Lambda event object
42-
:param Context context: Mock Lambda context
43-
:returns dict: Lamnda invocation result
86+
:param dict event: Lambda event object
87+
:param Context context: Mock Lambda context
88+
:returns dict: Lamnda invocation result
4489
"""
4590
# Get Lambda event
4691
event = self.get_event(httpMethod)
@@ -61,5 +106,9 @@ def invoke(self, httpMethod):
61106
self.wfile.write(body.encode())
62107

63108
@classmethod
64-
def set_proxy(cls, proxy):
109+
def set_proxy(cls, proxy, version):
110+
"""
111+
Set up LambdaRequestHandler.
112+
"""
65113
cls.proxy = proxy
114+
cls.version = version

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[build-system]
2+
requires = ["setuptools", "wheel"]
3+
build-backend = "setuptools.build_meta:__legacy__"

setup.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
from setuptools import find_packages
2-
from setuptools import setup
1+
from setuptools import (find_packages, setup)
32

43
with open('README.md', 'r') as readme:
54
long_description = readme.read()
@@ -17,6 +16,7 @@
1716
'Programming Language :: Python :: 3',
1817
'Programming Language :: Python :: 3.7',
1918
'Programming Language :: Python :: 3.8',
19+
'Programming Language :: Python :: 3.9',
2020
'Topic :: Utilities',
2121
],
2222
description='Simple HTTP server to invoke a Lambda function locally',

0 commit comments

Comments
 (0)