diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 115e1f0..d611edf 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -3,7 +3,7 @@ on: pull_request: push: schedule: - - cron: "11 21 * * *" + - cron: '11 21 * * *' jobs: pytest: runs-on: ubuntu-latest @@ -12,6 +12,7 @@ jobs: python: - 3.7 - 3.8 + - 3.9 steps: - uses: actions/checkout@v1 - uses: actions/setup-python@v1 diff --git a/Makefile b/Makefile index 877ccf7..14412f9 100644 --- a/Makefile +++ b/Makefile @@ -9,8 +9,7 @@ default: $(SDIST) clean: rm -rf dist -test: - py.test +test: coverage.xml upload: $(SDIST) twine upload $< @@ -18,5 +17,9 @@ upload: $(SDIST) 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 diff --git a/README.md b/README.md index dcfc86e..1d8c52b 100644 --- a/README.md +++ b/README.md @@ -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 +``` diff --git a/lambda_gateway/__init__.py b/lambda_gateway/__init__.py index 07dc2d4..20b86c7 100644 --- a/lambda_gateway/__init__.py +++ b/lambda_gateway/__init__.py @@ -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): diff --git a/lambda_gateway/__main__.py b/lambda_gateway/__main__.py index 98cc8e6..9e5c306 100644 --- a/lambda_gateway/__main__.py +++ b/lambda_gateway/__main__.py @@ -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: @@ -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', ) @@ -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', @@ -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 @@ -90,7 +108,9 @@ def run(httpd, base_path='/'): def main(): - """ Main entrypoint. """ + """ + Main entrypoint. + """ # Parse opts opts = get_opts() @@ -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 diff --git a/lambda_gateway/event_proxy.py b/lambda_gateway/event_proxy.py index 3d14b8b..e6123b3 100644 --- a/lambda_gateway/event_proxy.py +++ b/lambda_gateway/event_proxy.py @@ -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) @@ -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): @@ -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 { diff --git a/lambda_gateway/lambda_context.py b/lambda_gateway/lambda_context.py index 5e82497..c71eeb2 100644 --- a/lambda_gateway/lambda_context.py +++ b/lambda_gateway/lambda_context.py @@ -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() @@ -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: diff --git a/lambda_gateway/request_handler.py b/lambda_gateway/request_handler.py index 59e48d5..08d63f2 100644 --- a/lambda_gateway/request_handler.py +++ b/lambda_gateway/request_handler.py @@ -13,7 +13,9 @@ 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() @@ -21,26 +23,69 @@ def get_body(self): 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) @@ -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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b0471b7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta:__legacy__" \ No newline at end of file diff --git a/setup.py b/setup.py index 9d180f3..d47afa6 100644 --- a/setup.py +++ b/setup.py @@ -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() @@ -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', diff --git a/tests/test_event_proxy.py b/tests/test_event_proxy.py index 3fe98ca..ec362f2 100644 --- a/tests/test_event_proxy.py +++ b/tests/test_event_proxy.py @@ -28,44 +28,218 @@ def test_get_handler_error(self, handler): with pytest.raises(ValueError): self.subject.get_handler() - @pytest.mark.parametrize(('verb', 'path', 'status', 'message'), [ - ('GET', '/simple/', 200, 'OK'), - ('HEAD', '/simple/', 200, 'OK'), - ('POST', '/simple/', 200, 'OK'), + @pytest.mark.parametrize(('event', 'exp'), [ + ( + { + 'version': '1.0', + 'httpMethod': 'GET', + 'path': '/simple/', + }, + EventProxy.jsonify('GET', 200, message='OK'), + ), + ( + { + 'version': '1.0', + 'httpMethod': 'HEAD', + 'path': '/simple/', + }, + EventProxy.jsonify('HEAD', 200, message='OK'), + ), + ( + { + 'version': '1.0', + 'httpMethod': 'POST', + 'path': '/simple/', + 'body': '{"fizz": "buzz"}', + }, + EventProxy.jsonify('POST', 200, message='OK'), + ), + ( + { + 'version': '2.0', + 'rawPath': '/simple/', + 'requestContext': { + 'http': { + 'method': 'GET', + 'path': '/simple/', + }, + }, + }, + EventProxy.jsonify('GET', 200, message='OK'), + ), + ( + { + 'version': '2.0', + 'rawPath': '/simple/', + 'requestContext': { + 'http': { + 'method': 'HEAD', + 'path': '/simple/', + }, + }, + }, + EventProxy.jsonify('HEAD', 200, message='OK'), + ), + ( + { + 'version': '2.0', + 'rawPath': '/simple/', + 'body': '{"fizz": "buzz"}', + 'requestContext': { + 'http': { + 'method': 'POST', + 'path': '/simple/', + }, + }, + }, + EventProxy.jsonify('POST', 200, message='OK'), + ), ]) - def test_invoke_success(self, verb, path, status, message): + def test_invoke_success(self, event, exp): self.subject.get_handler = lambda: lambda event, context: exp - exp = EventProxy.jsonify(verb, status, message=message) - ret = self.subject.invoke({'httpMethod': verb, 'path': path}) + ret = self.subject.invoke(event) assert ret == exp - @pytest.mark.parametrize(('verb', 'path', 'status', 'message'), [ - ('GET', '/simple/', 502, 'Internal server error'), - ('HEAD', '/simple/', 502, 'Internal server error'), - ('POST', '/simple/', 502, 'Internal server error'), - ('GET', '/', 403, 'Forbidden'), - ('HEAD', '/', 403, 'Forbidden'), - ('POST', '/', 403, 'Forbidden'), + @pytest.mark.parametrize(('event', 'exp'), [ + ( + { + 'version': '2.0', + 'rawPath': '/simple/', + 'requestContext': { + 'http': { + 'method': 'GET', + 'path': '/simple/', + }, + }, + }, + EventProxy.jsonify('GET', 502, message='Internal server error'), + ), + ( + { + 'version': '2.0', + 'rawPath': '/simple/', + 'requestContext': { + 'http': { + 'method': 'HEAD', + 'path': '/simple/', + }, + }, + }, + EventProxy.jsonify('HEAD', 502, message='Internal server error'), + ), + ( + { + 'version': '2.0', + 'rawPath': '/simple/', + 'requestContext': { + 'http': { + 'method': 'POST', + 'path': '/simple/', + }, + }, + }, + EventProxy.jsonify('POST', 502, message='Internal server error'), + ), + ( + { + 'version': '2.0', + 'rawPath': '/', + 'requestContext': { + 'http': { + 'method': 'GET', + 'path': '/', + }, + }, + }, + EventProxy.jsonify('GET', 403, message='Forbidden'), + ), + ( + { + 'version': '2.0', + 'rawPath': '/', + 'requestContext': { + 'http': { + 'method': 'HEAD', + 'path': '/', + }, + }, + }, + EventProxy.jsonify('HEAD', 403, message='Forbidden'), + ), + ( + { + 'version': '2.0', + 'rawPath': '/', + 'requestContext': { + 'http': { + 'method': 'POST', + 'path': '/', + }, + }, + }, + EventProxy.jsonify('POST', 403, message='Forbidden'), + ), ]) - def test_invoke_error(self, verb, path, status, message): + def test_invoke_error(self, event, exp): def handler(event, context): raise Exception() self.subject.get_handler = lambda: handler - exp = EventProxy.jsonify(verb, status, message=message) - ret = self.subject.invoke({'httpMethod': verb, 'path': path}) + ret = self.subject.invoke(event) assert ret == exp - @pytest.mark.parametrize(('verb', 'path', 'status', 'message'), [ - ('GET', '/simple/', 504, 'Endpoint request timed out'), - ('HEAD', '/simple/', 504, 'Endpoint request timed out'), - ('POST', '/simple/', 504, 'Endpoint request timed out'), + @pytest.mark.parametrize(('event', 'exp'), [ + ( + { + 'version': '2.0', + 'rawPath': '/simple/', + 'requestContext': { + 'http': { + 'method': 'GET', + 'path': '/simple/', + }, + }, + }, + EventProxy.jsonify( + 'GET', 504, + message='Endpoint request timed out', + ), + ), + ( + { + 'version': '2.0', + 'rawPath': '/simple/', + 'requestContext': { + 'http': { + 'method': 'HEAD', + 'path': '/simple/', + }, + }, + }, + EventProxy.jsonify('HEAD', 504), + ), + ( + { + 'version': '2.0', + 'rawPath': '/simple/', + 'body': '{"fizz": "buzz"}', + 'requestContext': { + 'http': { + 'method': 'POST', + 'path': '/simple/', + }, + }, + }, + EventProxy.jsonify( + 'POST', 504, + message='Endpoint request timed out', + ), + ), ]) - def test_invoke_timeout(self, verb, path, status, message): + def test_invoke_timeout(self, event, exp): patch = 'lambda_gateway.event_proxy.EventProxy.invoke_async' with mock.patch(patch) as mock_invoke: mock_invoke.side_effect = asyncio.TimeoutError - exp = EventProxy.jsonify(verb, status, message=message) - ret = self.subject.invoke({'httpMethod': verb, 'path': path}) + ret = self.subject.invoke(event) assert ret == exp @pytest.mark.parametrize(('verb', 'statusCode', 'body', 'exp'), [ diff --git a/tests/test_request_handler.py b/tests/test_request_handler.py index 3cd0d6f..0e43232 100644 --- a/tests/test_request_handler.py +++ b/tests/test_request_handler.py @@ -14,39 +14,65 @@ class TestLambdaRequestHandler: def setup(self): self.subject = Mock(LambdaRequestHandler) self.subject.proxy = Mock(EventProxy) + self.subject.version = '2.0' + self.subject.get_event_v1 = \ + lambda x: LambdaRequestHandler.get_event_v1(self.subject, x) + self.subject.get_event_v2 = \ + lambda x: LambdaRequestHandler.get_event_v2(self.subject, x) - def set_request(self, verb, path='/', **params): + def set_request(self, verb, path='/', version='2.0', **params): + rawPath = path if verb in ['POST']: body = json.dumps({'data': 'POST_DATA'}) headers = {'Content-Length': len(body)} else: body = '' headers = {} - req = { - 'body': body, - 'headers': headers, - 'httpMethod': verb, - 'path': path, - 'queryStringParameters': params - } if params: path += f'?{urlencode(params)}' self.subject.headers = headers self.subject.path = path self.subject.rfile = io.BytesIO(body.encode()) self.subject.wfile = Mock() + + req = { + 'version': version, + 'body': body, + 'headers': headers, + 'queryStringParameters': params, + } + if version == '2.0': + req.update({ + 'routeKey': f'{verb} {rawPath}', + 'rawPath': rawPath, + 'rawQueryString': urlencode(params), + 'queryStringParameters': params, + 'requestContext': { + 'http': { + 'method': verb, + 'path': rawPath, + }, + }, + }) + elif version == '1.0': + req.update({ + 'httpMethod': verb, + 'path': rawPath, + 'queryStringParameters': params + }) + return req - @pytest.mark.parametrize( - ('proxy'), - [ - EventProxy('index.handler', '/simple/', None), - EventProxy('index.handler', '/simple/', 3), - ], - ) - def test_set_proxy(self, proxy): - LambdaRequestHandler.set_proxy(proxy) + @pytest.mark.parametrize(('proxy', 'version'), [ + (EventProxy('index.handler', '/simple/', None), '1.0'), + (EventProxy('index.handler', '/simple/', 3), '1.0'), + (EventProxy('index.handler', '/simple/', None), '2.0'), + (EventProxy('index.handler', '/simple/', 3), '2.0'), + ]) + def test_set_proxy(self, proxy, version): + LambdaRequestHandler.set_proxy(proxy, version) assert LambdaRequestHandler.proxy == proxy + assert LambdaRequestHandler.version == version @pytest.mark.parametrize('verb', ['GET', 'HEAD', 'POST']) def test_do(self, verb): @@ -59,23 +85,31 @@ def test_get_body(self, verb): ret = LambdaRequestHandler.get_body(self.subject) assert ret == req['body'] - @pytest.mark.parametrize('verb', ['GET', 'HEAD', 'POST']) - def test_get_event(self, verb): - req = self.set_request(verb) - self.subject.get_body.return_value = req['body'] - ret = LambdaRequestHandler.get_event(self.subject, verb) - assert ret == req - - @pytest.mark.parametrize('verb', ['GET', 'HEAD', 'POST']) - def test_get_event_with_qs(self, verb): - req = self.set_request(verb, '/', fizz='buzz', jazz='fuzz') - self.subject.get_body.return_value = req['body'] + @pytest.mark.parametrize(('verb', 'path', 'version', 'params'), [ + ('GET', '/', '1.0', dict(fizz='buzz')), + ('HEAD', '/', '1.0', dict(fizz='buzz')), + ('POST', '/', '1.0', dict(fizz='buzz')), + ('GET', '/', '2.0', dict(fizz='buzz')), + ('HEAD', '/', '2.0', dict(fizz='buzz')), + ('POST', '/', '2.0', dict(fizz='buzz')), + ]) + def test_get_event(self, verb, path, version, params): + exp = self.set_request(verb, path, version, **params) + self.subject.version = version + self.subject.get_body.return_value = exp['body'] ret = LambdaRequestHandler.get_event(self.subject, verb) - assert ret == req + assert ret == exp - @pytest.mark.parametrize('verb', ['GET', 'HEAD', 'POST']) - def test_invoke(self, verb): - req = self.set_request(verb) + @pytest.mark.parametrize(('verb', 'path', 'version', 'params'), [ + ('GET', '/', '1.0', dict(fizz='buzz')), + ('HEAD', '/', '1.0', dict(fizz='buzz')), + ('POST', '/', '1.0', dict(fizz='buzz')), + ('GET', '/', '2.0', dict(fizz='buzz')), + ('HEAD', '/', '2.0', dict(fizz='buzz')), + ('POST', '/', '2.0', dict(fizz='buzz')), + ]) + def test_invoke(self, verb, path, version, params): + req = self.set_request(verb, path, version, **params) self.subject.get_event.return_value = req self.subject.proxy.invoke.return_value = { 'body': 'OK',