├── .github └── workflows │ └── main.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── api_gateway_v2_to_wsgi.py ├── requirements-dev.txt ├── setup.cfg ├── setup.py ├── testing ├── data │ ├── cookies.json │ ├── get.json │ ├── headers.json │ ├── image.json │ ├── post.json │ └── query.json ├── example.md └── example │ ├── .gitignore │ ├── README.md │ ├── make-lambda │ ├── requirements.txt │ ├── sample_app.py │ └── tf │ ├── .gitignore │ ├── data │ └── placeholder_lambda.zip │ └── lambda_sample_app.tf ├── tests ├── __init__.py └── api_gateway_v2_to_wsgi_test.py └── tox.ini /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | on: 4 | push: 5 | branches: [main, test-me-*] 6 | tags: '*' 7 | pull_request: 8 | 9 | jobs: 10 | main: 11 | uses: asottile/workflows/.github/workflows/tox.yml@v1.0.0 12 | with: 13 | env: '["py38", "py39"]' 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | /.coverage 4 | /.tox 5 | /build 6 | /dist 7 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.5.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-yaml 8 | - id: debug-statements 9 | - id: double-quote-string-fixer 10 | - id: name-tests-test 11 | - id: requirements-txt-fixer 12 | - repo: https://github.com/asottile/setup-cfg-fmt 13 | rev: v2.5.0 14 | hooks: 15 | - id: setup-cfg-fmt 16 | - repo: https://github.com/asottile/reorder-python-imports 17 | rev: v3.12.0 18 | hooks: 19 | - id: reorder-python-imports 20 | args: [--py38-plus, --add-import, 'from __future__ import annotations'] 21 | - repo: https://github.com/asottile/add-trailing-comma 22 | rev: v3.1.0 23 | hooks: 24 | - id: add-trailing-comma 25 | - repo: https://github.com/asottile/pyupgrade 26 | rev: v3.15.0 27 | hooks: 28 | - id: pyupgrade 29 | args: [--py38-plus] 30 | - repo: https://github.com/hhatto/autopep8 31 | rev: v2.0.4 32 | hooks: 33 | - id: autopep8 34 | - repo: https://github.com/PyCQA/flake8 35 | rev: 6.1.0 36 | hooks: 37 | - id: flake8 38 | - repo: https://github.com/pre-commit/mirrors-mypy 39 | rev: v1.7.0 40 | hooks: 41 | - id: mypy 42 | additional_dependencies: [types-all] 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Anthony Sottile 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DEPRECATED 2 | 3 | use [apig-wsgi](https://github.com/adamchainz/apig-wsgi) instead 4 | 5 | ___ 6 | 7 | [![build status](https://github.com/asottile/api-gateway-v2-to-wsgi/actions/workflows/main.yml/badge.svg)](https://github.com/asottile/api-gateway-v2-to-wsgi/actions/workflows/main.yml) 8 | [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/asottile/api-gateway-v2-to-wsgi/main.svg)](https://results.pre-commit.ci/latest/github/asottile/api-gateway-v2-to-wsgi/main) 9 | 10 | api-gateway-v2-to-wsgi 11 | ====================== 12 | 13 | translation from the aws api gateway v2.0 lambda event to wsgi 14 | 15 | ## installation 16 | 17 | ```bash 18 | pip install api-gateway-v2-to-wsgi 19 | ``` 20 | 21 | ## usage 22 | 23 | ```python 24 | import api_gateway_v2_to_wsgi 25 | 26 | from ... import app 27 | 28 | # app is your wsgi callable, such as the `Flask(...)` object from `flask` 29 | lambda_handler = api_gateway_v2_to_wsgi.make_lambda_handler(app) 30 | ``` 31 | 32 | ## sample application 33 | 34 | for a full sample, see [testing/example](testing/example) 35 | 36 | ## more information 37 | 38 | for more information on how I set up my lambda, see 39 | [testing/example.md](testing/example.md). 40 | 41 | additionally, see the [api gateway documentation] (though it's not very good 42 | at the time of writing so glhf) 43 | 44 | seems the [lambda integration guide] is slightly better 45 | 46 | [api gateway documentation]: https://docs.aws.amazon.com/apigateway/index.html 47 | [lambda integration guide]: https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html 48 | -------------------------------------------------------------------------------- /api_gateway_v2_to_wsgi.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import base64 4 | import collections 5 | import io 6 | import sys 7 | from types import TracebackType 8 | from typing import Any 9 | from typing import Callable 10 | from typing import Dict 11 | from typing import Iterable 12 | from typing import Protocol 13 | from typing import Tuple 14 | from typing import Type 15 | 16 | _ExcInfo = Tuple[Type[BaseException], BaseException, TracebackType] 17 | 18 | 19 | class _StartResponse(Protocol): 20 | def __call__( 21 | self, 22 | status: str, 23 | headers: list[tuple[str, str]], 24 | exc_info: _ExcInfo | None = ..., 25 | ) -> Callable[[bytes | bytearray], Any]: 26 | ... 27 | 28 | 29 | _App = Callable[[Dict[str, Any], _StartResponse], Iterable[bytes]] 30 | _Handler = Callable[[Dict[str, Any], Dict[str, Any]], Dict[str, str]] 31 | 32 | 33 | class _Responder: 34 | def __init__(self) -> None: 35 | self.status_code = 500 36 | self.body = io.BytesIO() 37 | self.headers: dict[str, list[str]] = collections.defaultdict(list) 38 | 39 | def __call__( 40 | self, 41 | status: str, 42 | headers: list[tuple[str, str]], 43 | exc_info: _ExcInfo | None = None, 44 | ) -> Callable[[bytes | bytearray], Any]: 45 | if exc_info is not None: 46 | _, e, tb = exc_info 47 | raise e.with_traceback(tb) 48 | 49 | code, _, _ = status.partition(' ') 50 | self.status_code = int(code) 51 | for k, v in headers: 52 | self.headers[k].append(v) 53 | return self.body.write 54 | 55 | def response(self) -> dict[str, Any]: 56 | body_b = self.body.getvalue() 57 | try: 58 | body = body_b.decode() 59 | is_base64_encoded = False 60 | except UnicodeDecodeError: 61 | body = base64.b64encode(body_b).decode() 62 | is_base64_encoded = True 63 | 64 | return { 65 | 'isBase64Encoded': is_base64_encoded, 66 | 'statusCode': self.status_code, 67 | 'body': body, 68 | 'headers': {k: ','.join(v) for k, v in self.headers.items()}, 69 | } 70 | 71 | 72 | def _environ(event: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]: 73 | body = event.get('body', '') 74 | if event['isBase64Encoded']: 75 | body_b = base64.b64decode(body) 76 | else: 77 | body_b = body.encode() 78 | 79 | environ = { 80 | 'CONTENT_LENGTH': str(len(body_b)), 81 | 'CONTENT_TYPE': event['headers'].get('content-type', ''), 82 | # cookies are stripped out of the headers mapping 83 | 'HTTP_COOKIE': ';'.join(event.get('cookies', [])), 84 | 'PATH_INFO': event['rawPath'], 85 | 'QUERY_STRING': event['rawQueryString'], 86 | 'REMOTE_ADDR': event['requestContext']['http']['sourceIp'], 87 | 'REQUEST_METHOD': event['requestContext']['http']['method'], 88 | 'SCRIPT_NAME': '', 89 | 'SERVER_NAME': event['headers']['host'], 90 | 'SERVER_PORT': event['headers']['x-forwarded-port'], 91 | 'SERVER_PROTOCOL': event['requestContext']['http']['protocol'], 92 | 'meta.context': context, 93 | 'meta.event': event, 94 | 'wsgi.errors': sys.stderr, 95 | 'wsgi.input': io.BytesIO(body_b), 96 | 'wsgi.multiprocess': False, 97 | 'wsgi.multithread': False, 98 | 'wsgi.run_once': False, 99 | 'wsgi.url_scheme': event['headers']['x-forwarded-proto'], 100 | 'wsgi.version': (1, 0), 101 | } 102 | 103 | for k, v in event['headers'].items(): 104 | environ[f'HTTP_{k.upper().replace("-", "_")}'] = v 105 | 106 | return environ 107 | 108 | 109 | def make_lambda_handler(app: _App) -> _Handler: 110 | def handler( 111 | event: dict[str, Any], 112 | context: dict[str, Any], 113 | ) -> dict[str, Any]: 114 | responder = _Responder() 115 | for data in app(_environ(event, context), responder): 116 | responder.body.write(data) 117 | return responder.response() 118 | return handler 119 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | covdefaults 2 | coverage 3 | flask 4 | pytest 5 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = api_gateway_v2_to_wsgi 3 | version = 1.1.1 4 | description = translation from the aws api gateway v2.0 lambda event to wsgi 5 | long_description = file: README.md 6 | long_description_content_type = text/markdown 7 | url = https://github.com/asottile/api-gateway-v2-to-wsgi 8 | author = Anthony Sottile 9 | author_email = asottile@umich.edu 10 | license = MIT 11 | license_files = LICENSE 12 | classifiers = 13 | License :: OSI Approved :: MIT License 14 | Programming Language :: Python :: 3 15 | Programming Language :: Python :: 3 :: Only 16 | Programming Language :: Python :: Implementation :: CPython 17 | 18 | [options] 19 | py_modules = api_gateway_v2_to_wsgi 20 | python_requires = >=3.8 21 | 22 | [bdist_wheel] 23 | universal = True 24 | 25 | [coverage:run] 26 | plugins = covdefaults 27 | 28 | [mypy] 29 | check_untyped_defs = true 30 | disallow_any_generics = true 31 | disallow_incomplete_defs = true 32 | disallow_untyped_defs = true 33 | warn_redundant_casts = true 34 | warn_unused_ignores = true 35 | 36 | [mypy-testing.*] 37 | disallow_untyped_defs = false 38 | 39 | [mypy-tests.*] 40 | disallow_untyped_defs = false 41 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from setuptools import setup 4 | setup() 5 | -------------------------------------------------------------------------------- /testing/data/cookies.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "routeKey": "$default", 4 | "rawPath": "/cookie", 5 | "rawQueryString": "", 6 | "cookies": [ 7 | "a=1", 8 | "b=2" 9 | ], 10 | "headers": { 11 | "accept": "*/*", 12 | "content-length": "0", 13 | "host": "8o35w2r7ec.execute-api.us-east-1.amazonaws.com", 14 | "user-agent": "curl/7.68.0", 15 | "x-amzn-trace-id": "Root=1-5f37194d-68e6d6005de1bf8061a0a080", 16 | "x-forwarded-for": "76.14.26.166", 17 | "x-forwarded-port": "443", 18 | "x-forwarded-proto": "https" 19 | }, 20 | "requestContext": { 21 | "accountId": "952911408644", 22 | "apiId": "8o35w2r7ec", 23 | "domainName": "8o35w2r7ec.execute-api.us-east-1.amazonaws.com", 24 | "domainPrefix": "8o35w2r7ec", 25 | "http": { 26 | "method": "GET", 27 | "path": "/cookie", 28 | "protocol": "HTTP/1.1", 29 | "sourceIp": "76.14.26.166", 30 | "userAgent": "curl/7.68.0" 31 | }, 32 | "requestId": "RSDkJhvbIAMEJNQ=", 33 | "routeKey": "$default", 34 | "stage": "$default", 35 | "time": "14/Aug/2020:23:07:57 +0000", 36 | "timeEpoch": 1597446477668 37 | }, 38 | "isBase64Encoded": false 39 | } 40 | -------------------------------------------------------------------------------- /testing/data/get.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "routeKey": "$default", 4 | "rawPath": "/", 5 | "rawQueryString": "", 6 | "headers": { 7 | "accept": "*/*", 8 | "content-length": "0", 9 | "host": "kr8spsb5ti.execute-api.us-east-1.amazonaws.com", 10 | "user-agent": "curl/7.68.0", 11 | "x-amzn-trace-id": "Root=1-5f30b572-3f87b39ad007312beebc1a92", 12 | "x-forwarded-for": "76.14.26.166", 13 | "x-forwarded-port": "443", 14 | "x-forwarded-proto": "https" 15 | }, 16 | "requestContext": { 17 | "accountId": "952911408644", 18 | "apiId": "kr8spsb5ti", 19 | "domainName": "kr8spsb5ti.execute-api.us-east-1.amazonaws.com", 20 | "domainPrefix": "kr8spsb5ti", 21 | "http": { 22 | "method": "GET", 23 | "path": "/", 24 | "protocol": "HTTP/1.1", 25 | "sourceIp": "76.14.26.166", 26 | "userAgent": "curl/7.68.0" 27 | }, 28 | "requestId": "RCFJ1jzyoAMEPMQ=", 29 | "routeKey": "$default", 30 | "stage": "$default", 31 | "time": "10/Aug/2020:02:48:18 +0000", 32 | "timeEpoch": 1597027698026 33 | }, 34 | "isBase64Encoded": false 35 | } 36 | -------------------------------------------------------------------------------- /testing/data/headers.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "routeKey": "$default", 4 | "rawPath": "/", 5 | "rawQueryString": "", 6 | "headers": { 7 | "a": "1,2", 8 | "accept": "*/*", 9 | "b": "3", 10 | "content-length": "0", 11 | "host": "kr8spsb5ti.execute-api.us-east-1.amazonaws.com", 12 | "user-agent": "curl/7.68.0", 13 | "x-amzn-trace-id": "Root=1-5f30b574-365a0e2aa6c034f42de7fd5c", 14 | "x-forwarded-for": "76.14.26.166", 15 | "x-forwarded-port": "443", 16 | "x-forwarded-proto": "https" 17 | }, 18 | "requestContext": { 19 | "accountId": "952911408644", 20 | "apiId": "kr8spsb5ti", 21 | "domainName": "kr8spsb5ti.execute-api.us-east-1.amazonaws.com", 22 | "domainPrefix": "kr8spsb5ti", 23 | "http": { 24 | "method": "GET", 25 | "path": "/", 26 | "protocol": "HTTP/1.1", 27 | "sourceIp": "76.14.26.166", 28 | "userAgent": "curl/7.68.0" 29 | }, 30 | "requestId": "RCFKJjYloAMEPCQ=", 31 | "routeKey": "$default", 32 | "stage": "$default", 33 | "time": "10/Aug/2020:02:48:20 +0000", 34 | "timeEpoch": 1597027700047 35 | }, 36 | "isBase64Encoded": false 37 | } 38 | -------------------------------------------------------------------------------- /testing/data/image.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "routeKey": "$default", 4 | "rawPath": "/image.gif", 5 | "rawQueryString": "", 6 | "headers": { 7 | "accept": "*/*", 8 | "content-length": "0", 9 | "host": "kr8spsb5ti.execute-api.us-east-1.amazonaws.com", 10 | "user-agent": "curl/7.68.0", 11 | "x-amzn-trace-id": "Root=1-5f30b572-3f87b39ad007312beebc1a92", 12 | "x-forwarded-for": "76.14.26.166", 13 | "x-forwarded-port": "443", 14 | "x-forwarded-proto": "https" 15 | }, 16 | "requestContext": { 17 | "accountId": "952911408644", 18 | "apiId": "kr8spsb5ti", 19 | "domainName": "kr8spsb5ti.execute-api.us-east-1.amazonaws.com", 20 | "domainPrefix": "kr8spsb5ti", 21 | "http": { 22 | "method": "GET", 23 | "path": "/image.gif", 24 | "protocol": "HTTP/1.1", 25 | "sourceIp": "76.14.26.166", 26 | "userAgent": "curl/7.68.0" 27 | }, 28 | "requestId": "RCFJ1jzyoAMEPMQ=", 29 | "routeKey": "$default", 30 | "stage": "$default", 31 | "time": "10/Aug/2020:02:48:18 +0000", 32 | "timeEpoch": 1597027698026 33 | }, 34 | "isBase64Encoded": false 35 | } 36 | -------------------------------------------------------------------------------- /testing/data/post.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "routeKey": "$default", 4 | "rawPath": "/", 5 | "rawQueryString": "", 6 | "headers": { 7 | "accept": "*/*", 8 | "content-length": "2", 9 | "host": "kr8spsb5ti.execute-api.us-east-1.amazonaws.com", 10 | "user-agent": "curl/7.68.0", 11 | "x-amzn-trace-id": "Root=1-5f30b572-5cc5a3885283dde0a3d31dd0", 12 | "x-forwarded-for": "76.14.26.166", 13 | "x-forwarded-port": "443", 14 | "x-forwarded-proto": "https" 15 | }, 16 | "requestContext": { 17 | "accountId": "952911408644", 18 | "apiId": "kr8spsb5ti", 19 | "domainName": "kr8spsb5ti.execute-api.us-east-1.amazonaws.com", 20 | "domainPrefix": "kr8spsb5ti", 21 | "http": { 22 | "method": "POST", 23 | "path": "/", 24 | "protocol": "HTTP/1.1", 25 | "sourceIp": "76.14.26.166", 26 | "userAgent": "curl/7.68.0" 27 | }, 28 | "requestId": "RCFJ6g9LIAMEPHw=", 29 | "routeKey": "$default", 30 | "stage": "$default", 31 | "time": "10/Aug/2020:02:48:18 +0000", 32 | "timeEpoch": 1597027698517 33 | }, 34 | "body": "aGk=", 35 | "isBase64Encoded": true 36 | } 37 | -------------------------------------------------------------------------------- /testing/data/query.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "routeKey": "$default", 4 | "rawPath": "/wat", 5 | "rawQueryString": "x=1&x=2&y=3", 6 | "headers": { 7 | "accept": "*/*", 8 | "content-length": "0", 9 | "host": "kr8spsb5ti.execute-api.us-east-1.amazonaws.com", 10 | "user-agent": "curl/7.68.0", 11 | "x-amzn-trace-id": "Root=1-5f30b572-5b6cdf78d6754be062ecc598", 12 | "x-forwarded-for": "76.14.26.166", 13 | "x-forwarded-port": "443", 14 | "x-forwarded-proto": "https" 15 | }, 16 | "queryStringParameters": { 17 | "x": "1,2", 18 | "y": "3" 19 | }, 20 | "requestContext": { 21 | "accountId": "952911408644", 22 | "apiId": "kr8spsb5ti", 23 | "domainName": "kr8spsb5ti.execute-api.us-east-1.amazonaws.com", 24 | "domainPrefix": "kr8spsb5ti", 25 | "http": { 26 | "method": "GET", 27 | "path": "/wat", 28 | "protocol": "HTTP/1.1", 29 | "sourceIp": "76.14.26.166", 30 | "userAgent": "curl/7.68.0" 31 | }, 32 | "requestId": "RCFJ-gdaoAMEPQQ=", 33 | "routeKey": "$default", 34 | "stage": "$default", 35 | "time": "10/Aug/2020:02:48:18 +0000", 36 | "timeEpoch": 1597027698991 37 | }, 38 | "isBase64Encoded": false 39 | } 40 | -------------------------------------------------------------------------------- /testing/example.md: -------------------------------------------------------------------------------- 1 | ### placeholder app 2 | 3 | I used this placeholder app to get the sample events 4 | 5 | ```python 6 | lambda_handler = lambda event, context: event 7 | ``` 8 | 9 | ### requests 10 | 11 | ```bash 12 | BASE=https://kr8spsb5ti.execute-api.us-east-1.amazonaws.com 13 | curl "$BASE" | jq . > testing/data/get.json 14 | curl -XPOST -H 'content-type:' -d 'hi' "$BASE" | jq . > testing/data/post.json 15 | curl "$BASE/wat?x=1&x=2&y=3" | jq . > testing/data/query.json 16 | curl -H 'a: 1' -H 'a: 2' -H 'b: 3' "$BASE" | jq . > testing/data/headers.json 17 | curl -H 'cookie: a=1;b=2' "$BASE/cookie" | jq . > testing/data/cookies.json 18 | ``` 19 | -------------------------------------------------------------------------------- /testing/example/.gitignore: -------------------------------------------------------------------------------- 1 | /out.zip 2 | -------------------------------------------------------------------------------- /testing/example/README.md: -------------------------------------------------------------------------------- 1 | sample-app 2 | ========== 3 | 4 | this is a sample application using `flask` and this library to serve a lambda 5 | in aws 6 | 7 | ## running this example 8 | 9 | you can set up this application using the terraform provided in [./tf](./tf) 10 | 11 | ### initializing and creating the aws infrastructure 12 | 13 | the first step is to set up the terraform infrastructure. 14 | 15 | this creates a few things: 16 | - the necessary iam roles your lambda will use 17 | - a placeholder lambda (that always crashes) 18 | - the api gateway to connect to your lambda 19 | 20 | ```bash 21 | cd tf 22 | terraform init 23 | terraform apply 24 | cd .. 25 | ``` 26 | 27 | you'll answer "yes" to the prompt and your output will look something like 28 | this: 29 | 30 | ```console 31 | $ terraform apply 32 | 33 | ... 34 | 35 | Enter a value: yes 36 | 37 | aws_iam_role.sample_app: Creating... 38 | aws_iam_policy.sample_app_permissions_policy: Creating... 39 | aws_iam_role.sample_app: Creation complete after 1s [id=sample_app] 40 | aws_lambda_function.sample_app: Creating... 41 | aws_iam_policy.sample_app_permissions_policy: Creation complete after 1s [id=arn:aws:iam::952911408644:policy/terraform-20200810034840168600000001] 42 | aws_iam_role_policy_attachment.sample_app_permissions_policy_attach: Creating... 43 | aws_iam_role_policy_attachment.sample_app_permissions_policy_attach: Creation complete after 1s [id=sample_app-20200810034841916100000002] 44 | aws_lambda_function.sample_app: Still creating... [10s elapsed] 45 | aws_lambda_function.sample_app: Creation complete after 16s [id=sample_app] 46 | aws_apigatewayv2_api.sample_app_gateway: Creating... 47 | aws_apigatewayv2_api.sample_app_gateway: Creation complete after 2s [id=l45ezct9w4] 48 | aws_lambda_permission.sample_app_gateway: Creating... 49 | aws_lambda_permission.sample_app_gateway: Creation complete after 1s [id=terraform-20200810034859413700000003] 50 | 51 | Apply complete! Resources: 6 added, 0 changed, 0 destroyed. 52 | 53 | Outputs: 54 | 55 | api_gateway_address = "https://l45ezct9w4.execute-api.us-east-1.amazonaws.com" 56 | ``` 57 | 58 | the important part of this is the `api_gateway_address` portion, we'll be 59 | using that later! 60 | 61 | ### trying out the gateway 62 | 63 | now that the infrastructure is set up we should be able to try out the 64 | gateway! using the value from before, we'll curl the api gateway we created: 65 | 66 | ```console 67 | $ curl https://l45ezct9w4.execute-api.us-east-1.amazonaws.com/ && echo 68 | {"message":"Internal Server Error"} 69 | ``` 70 | 71 | hmmm right, we forgot to actually set up the lambda! 72 | 73 | ### build the lambda 74 | 75 | there's a small build script which'll create the necessary lambda zip to upload 76 | 77 | ```bash 78 | ./make-lambda 79 | ``` 80 | 81 | this'll spit out some pip output and then create `out.zip` 82 | 83 | ### uploading the lambda 84 | 85 | to change the lambda code, you'll push that zip to aws 86 | 87 | ```bash 88 | aws lambda update-function-code \ 89 | --function-name sample_app \ 90 | --zip-file fileb://out.zip 91 | ``` 92 | 93 | ### trying the app again 94 | 95 | ```console 96 | $ curl https://l45ezct9w4.execute-api.us-east-1.amazonaws.com/ && echo 97 | hello hello world 98 | ``` 99 | 100 | success! 101 | 102 | you can also try the other endpoint: 103 | 104 | ```console 105 | $ curl https://l45ezct9w4.execute-api.us-east-1.amazonaws.com/u/asottile && echo 106 | hello hello asottile 107 | ``` 108 | 109 | ### destroying the app 110 | 111 | ok demo complete! time to delete everything: 112 | 113 | ```bash 114 | cd tf 115 | terraform destroy 116 | cd .. 117 | ``` 118 | -------------------------------------------------------------------------------- /testing/example/make-lambda: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from __future__ import annotations 3 | 4 | import os.path 5 | import shutil 6 | import subprocess 7 | import sys 8 | import tempfile 9 | import zipfile 10 | 11 | 12 | def main() -> int: 13 | with zipfile.ZipFile( 14 | 'out.zip', 'w', 15 | compression=zipfile.ZIP_DEFLATED, 16 | compresslevel=9, 17 | ) as zipf: 18 | with tempfile.TemporaryDirectory() as tmpdir: 19 | subprocess.check_call(( 20 | sys.executable, '-mpip', 'install', '--target', tmpdir, 21 | '-r', 'requirements.txt', 22 | )) 23 | 24 | # remove the bin directory, it is not useful in lambda 25 | shutil.rmtree(os.path.join(tmpdir, 'bin')) 26 | 27 | for root, _, filenames in os.walk(tmpdir): 28 | for filename in filenames: 29 | if filename.endswith('.pyc'): 30 | continue 31 | abspath = os.path.join(root, filename) 32 | arcname = os.path.relpath(abspath, tmpdir) 33 | zipf.write(abspath, arcname=arcname) 34 | zipf.write('sample_app.py') 35 | return 0 36 | 37 | 38 | if __name__ == '__main__': 39 | raise SystemExit(main()) 40 | -------------------------------------------------------------------------------- /testing/example/requirements.txt: -------------------------------------------------------------------------------- 1 | ../.. 2 | flask 3 | -------------------------------------------------------------------------------- /testing/example/sample_app.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import flask 4 | 5 | import api_gateway_v2_to_wsgi 6 | 7 | app = flask.Flask(__name__) 8 | 9 | 10 | @app.route('/') 11 | def home() -> str: 12 | return 'hello hello world' 13 | 14 | 15 | @app.route('/u/') 16 | def profile(username: str) -> str: 17 | return f'hello hello {username}' 18 | 19 | 20 | @app.route('/image.gif') 21 | def image_route() -> flask.Response: 22 | return flask.Response( 23 | b'GIF89a\x01\x00\x01\x00\x80\x00\x00\xff\xff\xff\x00\x00\x00!\xf9' 24 | b'\x04\x01\x00\x00\x00\x00,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02' 25 | b'\x02D\x01\x00;', 26 | mimetype='image/gif', 27 | ) 28 | 29 | 30 | lambda_handler = api_gateway_v2_to_wsgi.make_lambda_handler(app) 31 | 32 | 33 | def main() -> int: 34 | app.run(port=8001) 35 | return 0 36 | 37 | 38 | if __name__ == '__main__': 39 | raise SystemExit(main()) 40 | -------------------------------------------------------------------------------- /testing/example/tf/.gitignore: -------------------------------------------------------------------------------- 1 | /*.tfstate 2 | /*.tfstate.* 3 | /.terraform 4 | /.terraform.lock.hcl 5 | -------------------------------------------------------------------------------- /testing/example/tf/data/placeholder_lambda.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asottile-archive/api-gateway-v2-to-wsgi/d63e3c4edcd02e943968b4749e6d52918496702e/testing/example/tf/data/placeholder_lambda.zip -------------------------------------------------------------------------------- /testing/example/tf/lambda_sample_app.tf: -------------------------------------------------------------------------------- 1 | provider "aws" { 2 | profile = "default" 3 | region = "us-east-1" 4 | } 5 | 6 | data "aws_iam_policy_document" "sample_app_assume_policy_document" { 7 | statement { 8 | actions = ["sts:AssumeRole"] 9 | principals { 10 | type = "Service" 11 | identifiers = ["lambda.amazonaws.com"] 12 | } 13 | } 14 | } 15 | resource "aws_iam_role" "sample_app" { 16 | name = "sample_app" 17 | assume_role_policy = data.aws_iam_policy_document.sample_app_assume_policy_document.json 18 | } 19 | 20 | resource "aws_lambda_function" "sample_app" { 21 | function_name = "sample_app" 22 | filename = "${path.module}/data/placeholder_lambda.zip" 23 | role = aws_iam_role.sample_app.arn 24 | handler = "sample_app.lambda_handler" 25 | runtime = "python3.8" 26 | } 27 | 28 | resource "aws_apigatewayv2_api" "sample_app_gateway" { 29 | name = "sample_app_gateway" 30 | protocol_type = "HTTP" 31 | target = aws_lambda_function.sample_app.arn 32 | } 33 | 34 | resource "aws_lambda_permission" "sample_app_gateway" { 35 | action = "lambda:InvokeFunction" 36 | function_name = aws_lambda_function.sample_app.arn 37 | principal = "apigateway.amazonaws.com" 38 | 39 | source_arn = "${aws_apigatewayv2_api.sample_app_gateway.execution_arn}/*/*" 40 | } 41 | 42 | output "api_gateway_address" { 43 | value = aws_apigatewayv2_api.sample_app_gateway.api_endpoint 44 | } 45 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asottile-archive/api-gateway-v2-to-wsgi/d63e3c4edcd02e943968b4749e6d52918496702e/tests/__init__.py -------------------------------------------------------------------------------- /tests/api_gateway_v2_to_wsgi_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import os.path 5 | import sys 6 | 7 | import flask 8 | import pytest 9 | 10 | import api_gateway_v2_to_wsgi 11 | 12 | HERE = os.path.abspath(os.path.dirname(__file__)) 13 | 14 | app = flask.Flask(__name__) 15 | 16 | 17 | @app.route('/', methods=['GET']) 18 | def index(): 19 | return f'''\ 20 | GET 21 | full url: {flask.url_for("index", _external=True)} 22 | a header: {flask.request.headers.getlist('a')} 23 | b header: {flask.request.headers.getlist('b')} 24 | ''' 25 | 26 | 27 | @app.route('/', methods=['POST']) 28 | def post_index(): 29 | return f'''\ 30 | POST 31 | data: {flask.request.data!r} 32 | ''' 33 | 34 | 35 | @app.route('/wat') 36 | def query_route(): 37 | return f'''\ 38 | wat route 39 | x param: {flask.request.args.getlist('x')} 40 | y param: {flask.request.args.getlist('y')} 41 | ''' 42 | 43 | 44 | @app.route('/cookie') 45 | def cookies_route(): 46 | return f'''\ 47 | cookies route 48 | a cookie: {flask.request.cookies.get('a')!r} 49 | b cookie: {flask.request.cookies.get('b')!r} 50 | ''' 51 | 52 | 53 | @app.route('/image.gif') 54 | def image_route(): 55 | return flask.Response( 56 | b'GIF89a\x01\x00\x01\x00\x80\x00\x00\xff\xff\xff\x00\x00\x00!\xf9' 57 | b'\x04\x01\x00\x00\x00\x00,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02' 58 | b'\x02D\x01\x00;', 59 | mimetype='image/gif', 60 | ) 61 | 62 | 63 | handler = api_gateway_v2_to_wsgi.make_lambda_handler(app) 64 | 65 | 66 | def _event(name): 67 | with open(os.path.join(HERE, f'../testing/data/{name}.json')) as f: 68 | return json.load(f) 69 | 70 | 71 | def test_get_request(): 72 | resp = handler(_event('get'), {}) 73 | expected_body = '''\ 74 | GET 75 | full url: https://kr8spsb5ti.execute-api.us-east-1.amazonaws.com/ 76 | a header: [] 77 | b header: [] 78 | ''' 79 | assert resp == { 80 | 'statusCode': 200, 81 | 'isBase64Encoded': False, 82 | 'body': expected_body, 83 | 'headers': { 84 | 'Content-Length': '96', 85 | 'Content-Type': 'text/html; charset=utf-8', 86 | }, 87 | } 88 | 89 | 90 | def test_post_request(): 91 | resp = handler(_event('post'), {}) 92 | expected_body = '''\ 93 | POST 94 | data: b'hi' 95 | ''' 96 | assert resp == { 97 | 'statusCode': 200, 98 | 'isBase64Encoded': False, 99 | 'body': expected_body, 100 | 'headers': { 101 | 'Content-Length': '17', 102 | 'Content-Type': 'text/html; charset=utf-8', 103 | }, 104 | } 105 | 106 | 107 | def test_query_string(): 108 | resp = handler(_event('query'), {}) 109 | expected_body = '''\ 110 | wat route 111 | x param: ['1', '2'] 112 | y param: ['3'] 113 | ''' 114 | assert resp == { 115 | 'statusCode': 200, 116 | 'isBase64Encoded': False, 117 | 'body': expected_body, 118 | 'headers': { 119 | 'Content-Length': '45', 120 | 'Content-Type': 'text/html; charset=utf-8', 121 | }, 122 | } 123 | 124 | 125 | def test_multi_headers(): 126 | resp = handler(_event('headers'), {}) 127 | # XXX: amazon bumbles mutli-headers into a single value 128 | expected_body = '''\ 129 | GET 130 | full url: https://kr8spsb5ti.execute-api.us-east-1.amazonaws.com/ 131 | a header: ['1,2'] 132 | b header: ['3'] 133 | ''' 134 | assert resp == { 135 | 'statusCode': 200, 136 | 'isBase64Encoded': False, 137 | 'body': expected_body, 138 | 'headers': { 139 | 'Content-Length': '104', 140 | 'Content-Type': 'text/html; charset=utf-8', 141 | }, 142 | } 143 | 144 | 145 | def test_raising_error(): 146 | # flask doesn't do this, so we make a silly app that does 147 | try: 148 | raise AssertionError('wat') 149 | except AssertionError: 150 | exc_info = sys.exc_info() 151 | 152 | def app(environ, handle_response): 153 | handle_response('500 error', [], exc_info=exc_info) 154 | 155 | error_app = api_gateway_v2_to_wsgi.make_lambda_handler(app) 156 | 157 | with pytest.raises(AssertionError) as excinfo: 158 | error_app(_event('get'), {}) 159 | 160 | msg, = excinfo.value.args 161 | assert msg == 'wat' 162 | 163 | 164 | def test_cookies(): 165 | resp = handler(_event('cookies'), {}) 166 | expected_body = '''\ 167 | cookies route 168 | a cookie: '1' 169 | b cookie: '2' 170 | ''' 171 | assert resp == { 172 | 'statusCode': 200, 173 | 'isBase64Encoded': False, 174 | 'body': expected_body, 175 | 'headers': { 176 | 'Content-Length': '42', 177 | 'Content-Type': 'text/html; charset=utf-8', 178 | }, 179 | } 180 | 181 | 182 | def test_binary_reposnse(): 183 | resp = handler(_event('image'), {}) 184 | assert resp == { 185 | 'statusCode': 200, 186 | 'isBase64Encoded': True, 187 | 'body': 'R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==', 188 | 'headers': { 189 | 'Content-Length': '43', 190 | 'Content-Type': 'image/gif', 191 | }, 192 | } 193 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py38,py39,pre-commit 3 | 4 | [testenv] 5 | deps = -rrequirements-dev.txt 6 | commands = 7 | coverage erase 8 | coverage run -m pytest {posargs:tests} 9 | coverage report 10 | 11 | [testenv:pre-commit] 12 | skip_install = true 13 | deps = pre-commit 14 | commands = pre-commit run --all-files --show-diff-on-failure 15 | 16 | [pep8] 17 | ignore = E265,E501,W504 18 | --------------------------------------------------------------------------------