├── .coveragerc ├── .github └── workflows │ ├── docs.yml │ ├── publish.yml │ └── test.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── docs ├── CNAME ├── adapter.md ├── asgi-frameworks.md ├── contributing.md ├── external-links.md ├── http.md ├── index.md └── lifespan.md ├── mangum ├── __init__.py ├── adapter.py ├── exceptions.py ├── handlers │ ├── __init__.py │ ├── alb.py │ ├── api_gateway.py │ ├── lambda_at_edge.py │ └── utils.py ├── protocols │ ├── __init__.py │ ├── http.py │ └── lifespan.py ├── py.typed └── types.py ├── mkdocs.yml ├── pyproject.toml ├── scripts ├── README.md ├── check ├── docs ├── lint ├── setup └── test ├── tests ├── __init__.py ├── conftest.py ├── handlers │ ├── __init__.py │ ├── test_alb.py │ ├── test_api_gateway.py │ ├── test_custom.py │ ├── test_http_gateway.py │ └── test_lambda_at_edge.py ├── test_adapter.py ├── test_http.py └── test_lifespan.py └── uv.lock /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = 3 | mangum 4 | omit = 5 | tests/* -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Documentation 2 | 3 | on: 4 | push: 5 | tags: 6 | - "**" 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | deploy: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Configure Git Credentials 18 | run: | 19 | git config user.name github-actions[bot] 20 | git config user.email 41898282+github-actions[bot]@users.noreply.github.com 21 | 22 | - name: Install uv 23 | uses: astral-sh/setup-uv@v2 24 | with: 25 | version: "0.4.12" 26 | enable-cache: true 27 | 28 | - name: Set up Python 29 | run: uv python install 3.12 30 | 31 | - name: Install dependencies 32 | run: uv sync --frozen 33 | 34 | - run: uv run mkdocs gh-deploy --force 35 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python Package 2 | 3 | on: 4 | push: 5 | tags: 6 | - "**" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | outputs: 12 | version: ${{ steps.inspect_package.outputs.version }} 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Install uv 17 | uses: astral-sh/setup-uv@v2 18 | with: 19 | version: "0.4.12" 20 | enable-cache: true 21 | 22 | - name: Set up Python 23 | run: uv python install 3.12 24 | 25 | - name: Build package 26 | run: uv build 27 | 28 | - name: Inspect package version 29 | id: inspect_package 30 | run: | 31 | version=$(uvx hatchling version) 32 | echo "version=$version" >> "$GITHUB_OUTPUT" 33 | 34 | - name: Upload package 35 | uses: actions/upload-artifact@v4 36 | with: 37 | name: package-distributions 38 | path: dist/ 39 | 40 | pypi-publish: 41 | runs-on: ubuntu-latest 42 | needs: build 43 | 44 | permissions: 45 | id-token: write 46 | 47 | environment: 48 | name: pypi 49 | url: https://pypi.org/project/mangum/${{ needs.build.outputs.version }} 50 | 51 | steps: 52 | - name: Download package 53 | uses: actions/download-artifact@v4 54 | with: 55 | name: package-distributions 56 | path: dist/ 57 | 58 | - name: Publish distribution 📦 to PyPI 59 | uses: pypa/gh-action-pypi-publish@release/v1 60 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["main"] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Install uv 19 | uses: astral-sh/setup-uv@v2 20 | with: 21 | version: "0.4.12" 22 | enable-cache: true 23 | 24 | - name: Set up Python ${{ matrix.python-version }} 25 | run: uv python install ${{ matrix.python-version }} 26 | 27 | - name: Install dependencies 28 | run: uv sync --python ${{ matrix.python-version }} --frozen 29 | 30 | - name: Run linters 31 | run: scripts/check 32 | 33 | - name: Run tests 34 | run: scripts/test 35 | 36 | # https://github.com/marketplace/actions/alls-green#why used for branch protection checks 37 | check: 38 | if: always() 39 | needs: [test] 40 | runs-on: ubuntu-latest 41 | steps: 42 | - name: Decide whether the needed jobs succeeded or failed 43 | uses: re-actors/alls-green@release/v1 44 | with: 45 | jobs: ${{ toJSON(needs) }} 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # IDE Settings 107 | .idea/ 108 | .vscode 109 | .devcontainer 110 | 111 | .DS_Store 112 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.19.0 2 | 3 | * Add support for [Lifespan State](https://asgi.readthedocs.io/en/latest/specs/lifespan.html#lifespan-state). 4 | 5 | # 0.18.0 6 | 7 | No changes were made compared to 0.18.0a1. 8 | 9 | # 0.18.0a1 10 | 11 | * Support Python 3.13 by @Kludex in https://github.com/Kludex/mangum/pull/327 12 | 13 | # 0.17.0 14 | 15 | * Remove 3.6 reference from frameworks docs by @aminalaee in https://github.com/jordaneremieff/mangum/pull/278 16 | * Add new blog post to external links by @aminalaee in https://github.com/jordaneremieff/mangum/pull/279 17 | * Add exclude_headers parameter by @aminalaee in https://github.com/jordaneremieff/mangum/pull/280 18 | 19 | # 0.16.0 20 | 21 | * Link to deployment tutorial by @simonw in https://github.com/jordaneremieff/mangum/pull/274 22 | * Added text_mime_types argument by @georgebv in https://github.com/jordaneremieff/mangum/pull/277 23 | 24 | # 0.15.1 25 | 26 | * Mention that Django works fine too by @WhyNotHugo in https://github.com/jordaneremieff/mangum/pull/261 27 | * Add vnd.oai.openapi to mime type list that are not base64 encoded by @khamaileon in https://github.com/jordaneremieff/mangum/pull/271 28 | 29 | # 0.15.0 30 | 31 | * Relax type annotations, refactor custom handler inferences, naming by @jordaneremieff in https://github.com/jordaneremieff/mangum/pull/259 32 | 33 | # 0.14.1 34 | 35 | * Remove references to Python 3.6, update setup.py. by @jordaneremieff in https://github.com/jordaneremieff/mangum/pull/246 36 | 37 | ## 0.14.0 38 | 39 | * Removing Websocket from docs by @aminalaee in https://github.com/jordaneremieff/mangum/pull/241 40 | * Replace abstract handlers, customer handlers, type annotations, refactoring. by @jordaneremieff in https://github.com/jordaneremieff/mangum/pull/242 41 | 42 | ## 0.13.0 43 | 44 | * Remove WebSocket support and misc cleanup/removals by @jordaneremieff in https://github.com/jordaneremieff/mangum/pull/234 45 | * Replace awslambdaric-stubs with new types, refactor import style. by @jordaneremieff in https://github.com/jordaneremieff/mangum/pull/235 46 | * Improve logging by @aminalaee in https://github.com/jordaneremieff/mangum/pull/230 47 | 48 | ## 0.12.4 49 | 50 | * HTTP Gateway V2: Remove use of obsolete multiValueHeaders by @IlyaSukhanov in https://github.com/jordaneremieff/mangum/pull/216 51 | * mypy - Argument "api_gateway_base_path" to "Mangum" has incompatible type "str"; expected "Dict[str, Any]" by @relsunkaev in https://github.com/jordaneremieff/mangum/pull/220 52 | * Added explicit python3.9 and 3.10 support by @relsunkaev in https://github.com/jordaneremieff/mangum/pull/224 53 | * Fix aws_api_gateway handler not accepting combined headers and multiValueHeaders by @Feriixu in https://github.com/jordaneremieff/mangum/pull/229 54 | 55 | ## 0.12.3 56 | 57 | * Fix unhandled `api_gateway_base_path` in `AwsHttpGateway`. [#200](https://github.com/jordaneremieff/mangum/pull/204). Thanks [xpayn](https://github.com/xpayn)! 58 | 59 | ## 0.12.2 60 | 61 | * Exclude `tests/` directory from package. [#200](https://github.com/jordaneremieff/mangum/pull/200). Thanks [bradsbrown](https://github.com/bradsbrown)! 62 | 63 | ## 0.12.1 64 | 65 | * Make `boto3` optional [#197](https://github.com/jordaneremieff/mangum/pull/197). 66 | 67 | ## 0.12.0 68 | 69 | * Reintroduce WebSocket support [#190](https://github.com/jordaneremieff/mangum/pull/190). Thanks [eduardovra](https://github.com/eduardovra)! 70 | 71 | * Resolve several issues with ALB/ELB support [#184](https://github.com/jordaneremieff/mangum/pull/184), [#189](https://github.com/jordaneremieff/mangum/pull/189), [#186](https://github.com/jordaneremieff/mangum/pull/186), [#182](https://github.com/jordaneremieff/mangum/pull/182). Thanks [nathanglover](https://github.com/nathanglover) & [jurasofish](https://github.com/jurasofish)! 72 | 73 | * Refactor handlers to be separate from core logic [#170](https://github.com/jordaneremieff/mangum/pull/170). Thanks [four43](https://github.com/four43)! 74 | 75 | ## 0.11.0 76 | 77 | * Remove deprecated `enable_lifespan` parameter [#109](https://github.com/jordaneremieff/mangum/issues/109). 78 | 79 | * Include API Gateway v2 event cookies in scope headers [#153](https://github.com/jordaneremieff/mangum/pull/153). Thanks [araki-yzrh](https://github.com/araki-yzrh)! 80 | 81 | * Support ELB and fix APIGW v2 cookies response [#155](https://github.com/jordaneremieff/mangum/pull/155). Thanks [araki-yzrh](https://github.com/araki-yzrh)! 82 | 83 | * Add flake8 to CI checks [#157](https://github.com/jordaneremieff/mangum/pull/157). Thanks [emcpow2](https://github.com/emcpow2)! 84 | 85 | * Add type hints for lambda handler context parameter [#158](https://github.com/jordaneremieff/mangum/pull/158). Thanks [emcpow2](https://github.com/emcpow2)! 86 | 87 | * Extract ASGI scope creation into function [#162](https://github.com/jordaneremieff/mangum/pull/162). Thanks [emcpow2](https://github.com/emcpow2)! 88 | 89 | ## 0.10.0 90 | 91 | * Remove WebSocket support to focus on HTTP [#127](https://github.com/jordaneremieff/mangum/issues/127). 92 | 93 | * Support multiValue headers in response [#129](https://github.com/jordaneremieff/mangum/pull/129). Thanks [@koxudaxi](https://github.com/koxudaxi)! 94 | 95 | * Fix duplicate test names [#134](https://github.com/jordaneremieff/mangum/pull/134). Thanks [@a-feld](https://github.com/a-feld)! 96 | 97 | * Run tests and release package using GitHub Actions [#131](https://github.com/jordaneremieff/mangum/issues/131). Thanks [@simonw](https://github.com/simonw)! 98 | 99 | * Only prefix a slash on the api_gateway_base_path if needed [#138](https://github.com/jordaneremieff/mangum/pull/138). Thanks [@dspatoulas](https://github.com/dspatoulas)! 100 | 101 | * Add support to Brotli compress [#139](https://github.com/jordaneremieff/mangum/issues/139). Thanks [@fullonic](https://github.com/fullonic)! 102 | 103 | ## 0.9.2 104 | 105 | * Make boto3 dependency optional [#115](https://github.com/jordaneremieff/mangum/pull/115) 106 | 107 | ## 0.9.1 108 | 109 | * Bugfix lifespan startup behaviour and refactor lifespan cycle, deprecate `enable_lifespan` parameter, document protocols. [#108](https://github.com/jordaneremieff/mangum/pull/108) 110 | 111 | ## 0.9.0 112 | 113 | * Resolve issue with `rawQueryString` in HTTP APIs using wrong type [#105](https://github.com/jordaneremieff/mangum/issues/105) 114 | 115 | * Implement new WebSocket storage backends for managing connections (PostgreSQL, Redis, DyanmoDB, S3, SQlite) using a single `dsn` configuration parameter [#103](https://github.com/jordaneremieff/mangum/pull/103) 116 | 117 | ## pre-0.9.0 118 | 119 | I did not maintain a CHANGELOG prior to 0.9.0, however, I still would like to include a thank you to following people: 120 | 121 | [@lsorber](https://github.com/lsorber) 122 | [@SKalt](https://github.com/SKalt) 123 | [@koxudaxi](https://github.com/koxudaxi) 124 | [@zachmullen](https://github.com/zachmullen) 125 | [@remorses](https://github.com/remorses) 126 | [@allan-simon](https://github.com/allan-simon) 127 | [@jaehyeon-kim](https://github.com/jaehyeon-kim) 128 | 129 | Your contributions to previous releases have greatly improved this project and are very much appreciated. 130 | 131 | Special thanks to [@tomchristie](https://github.com/tomchristie) for all of his support, encouragement, and guidance early on, and [@rajeev](https://github.com/rajeev) for inspiring this project. 132 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Mangum 2 | 3 | Hello. Contributions to this project are highly encouraged and appreciated. This document will outline some general guidelines for how to get started. 4 | 5 | ## Contents 6 | 7 | - [Contributing to Mangum](#contributing-to-mangum) 8 | - [Contents](#contents) 9 | - [Creating a pull request](#creating-a-pull-request) 10 | - [Setting up the repository](#setting-up-the-repository) 11 | - [Developing the project locally](#developing-the-project-locally) 12 | - [Setup](#setup) 13 | - [Test](#test) 14 | - [Coverage requirements](#coverage-requirements) 15 | - [Lint](#lint) 16 | - [Code style and formatting](#code-style-and-formatting) 17 | - [Static type checking](#static-type-checking) 18 | - [Using the issue tracker](#using-the-issue-tracker) 19 | - [Technical support](#technical-support) 20 | - [Feature requests](#feature-requests) 21 | - [Thank you](#thank-you) 22 | 23 | ## Creating a pull request 24 | 25 | Non-trivial changes, especially those that could impact existing behaviour, should have an associated issue created for discussion. An issue isn't strictly required for larger changes, but it can be helpful to discuss first. 26 | 27 | Minor changes generally should not require a new issue and can be explained in the pull request description. 28 | 29 | ### Setting up the repository 30 | 31 | To create a pull request, you must first [fork](https://docs.github.com/en/free-pro-team@latest/github/collaborating-with-issues-and-pull-requests/about-forks) the repository in GitHub, then clone the fork locally. 32 | 33 | ```shell 34 | git clone git@github.com:/mangum.git 35 | ``` 36 | 37 | Then add the upstream remote to keep the forked repo in sync with the original. 38 | 39 | ```shell 40 | cd mangum 41 | git remote add upstream git://github.com/jordaneremieff/mangum.git 42 | git fetch upstream 43 | ``` 44 | 45 | Then to keep in sync with changes in the primary repository, you pull the upstream changes into your local fork. 46 | 47 | ```shell 48 | git pull upstream main 49 | ``` 50 | 51 | ## Developing the project locally 52 | 53 | There are a few scripts in place to assist with local development, the following scripts are located in the `/scripts` directory: 54 | 55 | ### Setup 56 | 57 | Running the setup script will create a local Python virtual environment. It assumes that `python3.7` is available in the path and will install the development dependencies located in `requirements.txt`. 58 | 59 | Additionally, [uv](https://docs.astral.sh/uv/getting-started/installation/) needs to be installed on the system for the script to run properly. 60 | 61 | ```shell 62 | ./scripts/setup 63 | ``` 64 | 65 | Alternatively, you may create a virtual environment and install the requirements manually: 66 | 67 | ``` 68 | python -m venv venv 69 | . venv/bin/active 70 | pip install -r requirements.txt 71 | ``` 72 | 73 | This environment is used to run the tests for Python versions 3.8, 3.9, 3.10, 3.11, 3.12 and 3.13. 74 | 75 | ### Test 76 | 77 | The test script will run all the test cases with [PyTest](https://docs.pytest.org/en/stable/) using the path for the virtual environment created in the setup step (above). 78 | 79 | ```shell 80 | ./scripts/test 81 | ``` 82 | 83 | It also runs [Coverage](https://coverage.readthedocs.io/en/coverage-5.3/) to produce a code coverage report. 84 | 85 | #### Coverage requirements 86 | 87 | The coverage script is intended to fail under 100% test coverage, but this is not a strict requirement for contributions. Generally speaking at least one test should be included in a PR, but it is okay to use `# pragma: no cover` comments in the code to exclude specific coverage cases from the build. 88 | 89 | ### Lint 90 | 91 | The linting script will handle running [mypy](https://github.com/python/mypy) for static type checking, and [black](https://github.com/psf/black) for code formatting. 92 | 93 | ```shell 94 | ./scripts/lint 95 | ``` 96 | 97 | #### Code style and formatting 98 | 99 | Black formatting is required for all files with a maximum line-length of `88` (black's default) and double-quotes `"` are preferred over single-quotes `'`, otherwise there aren't specific style guidelines. 100 | 101 | #### Static type checking 102 | 103 | Mypy is used to handle static type checking in the build, and [type annotations](https://mypy.readthedocs.io/en/stable/cheat_sheet_py3.html) should be included when making changes or additions to the code. However, it is okay to use `# type: ignore` comments when it is unclear what type to use, or if the annotation required to pass the type checker significantly decreases readability. 104 | 105 | ## Using the issue tracker 106 | 107 | The issue [tracker](https://github.com/jordaneremieff/mangum/issues) can be used for different types of discussion, but it is mainly intended for items that are relevant to this project specifically. 108 | 109 | Here are a few things you might consider before opening a new issue: 110 | 111 | - Is this covered in the [documentation](https://mangum.fastapiexpert.com/)? 112 | 113 | - Is there already a related issue in the [tracker](https://github.com/Kludex/mangum/issues)? 114 | 115 | - Is this a problem related to Mangum itself or a third-party dependency? 116 | 117 | It may still be perfectly valid to open an issue if one or more of these is true, but thinking about these questions might help reveal an existing answer sooner. 118 | 119 | ### Technical support 120 | 121 | You may run into problems running Mangum that are related to a deployment tool (e.g. [Serverless Framework](https://www.serverless.com/)), an ASGI framework (e.g. [FastAPI](https://fastapi.tiangolo.com/)), or some other external dependency. It is okay to use the tracker to resolve these kinds of issues, but keep in mind that this project does not guaruntee support for all the features of any specific ASGI framework or external tool. 122 | 123 | **Note**: These issues will typlically be closed, but it is fine to continue discussion on a closed issue. These issues will be re-opened only if a problem is discovered in Mangum itself. 124 | 125 | ### Feature requests 126 | 127 | This project is intended to be small and focused on providing an adapter class for ASGI applications deployed in AWS Lambda. Feature requests related to this use-case will generally be considered, but larger features that increase the overall scope of Mangum are less likely to be included. 128 | 129 | If you have a large feature request, please make an issue with sufficient detail and it can be discussed. Some feature requests may end up being rejected initially and re-considered later. 130 | 131 | ## Thank you 132 | 133 | :) 134 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Jordan Eremieff 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mangum 2 | 3 | 4 | Package version 5 | 6 | PyPI - Python Version 7 | 8 | Mangum is an adapter for running [ASGI](https://asgi.readthedocs.io/en/latest/) applications in AWS Lambda to handle Function URL, API Gateway, ALB, and Lambda@Edge events. 9 | 10 | ***Documentation***: https://mangum.fastapiexpert.com/ 11 | 12 | ## Features 13 | 14 | - Event handlers for API Gateway [HTTP](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api.html) and [REST](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-rest-api.html) APIs, [Application Load Balancer](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/lambda-functions.html), [Function URLs](https://docs.aws.amazon.com/lambda/latest/dg/lambda-urls.html), and [CloudFront Lambda@Edge](https://docs.aws.amazon.com/lambda/latest/dg/lambda-edge.html). 15 | 16 | - Compatibility with ASGI application frameworks, such as [Starlette](https://www.starlette.io/), [FastAPI](https://fastapi.tiangolo.com/), [Quart](https://pgjones.gitlab.io/quart/) and [Django](https://www.djangoproject.com/). 17 | 18 | - Support for binary media types and payload compression in API Gateway using GZip or Brotli. 19 | 20 | - Works with existing deployment and configuration tools, including [Serverless Framework](https://www.serverless.com/) and [AWS SAM](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/what-is-sam.html). 21 | 22 | - Startup and shutdown [lifespan](https://asgi.readthedocs.io/en/latest/specs/lifespan.html) events. 23 | 24 | ## Installation 25 | 26 | ```shell 27 | pip install mangum 28 | ``` 29 | 30 | ## Example 31 | 32 | ```python 33 | from mangum import Mangum 34 | 35 | async def app(scope, receive, send): 36 | await send( 37 | { 38 | "type": "http.response.start", 39 | "status": 200, 40 | "headers": [[b"content-type", b"text/plain; charset=utf-8"]], 41 | } 42 | ) 43 | await send({"type": "http.response.body", "body": b"Hello, world!"}) 44 | 45 | 46 | handler = Mangum(app, lifespan="off") 47 | ``` 48 | 49 | Or using a framework: 50 | 51 | ```python 52 | from fastapi import FastAPI 53 | from mangum import Mangum 54 | 55 | app = FastAPI() 56 | 57 | 58 | @app.get("/") 59 | def read_root(): 60 | return {"Hello": "World"} 61 | 62 | 63 | @app.get("/items/{item_id}") 64 | def read_item(item_id: int, q: str = None): 65 | return {"item_id": item_id, "q": q} 66 | 67 | handler = Mangum(app, lifespan="off") 68 | ``` 69 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | ## Security contact information 2 | 3 | To report a security vulnerability, please use the 4 | [Tidelift security contact](https://tidelift.com/security). 5 | Tidelift will coordinate the fix and disclosure. 6 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | mangum.fastapiexpert.com 2 | -------------------------------------------------------------------------------- /docs/adapter.md: -------------------------------------------------------------------------------- 1 | # Adapter 2 | 3 | The heart of Mangum is the adapter class. It is a configurable wrapper that allows any [ASGI](https://asgi.readthedocs.io/en/latest/) application (or framework) to run in an [AWS Lambda](https://aws.amazon.com/lambda/) deployment. The adapter accepts a number of keyword arguments to configure settings related to logging, HTTP responses, ASGI lifespan, and API Gateway configuration. 4 | 5 | ```python 6 | handler = Mangum( 7 | app, 8 | lifespan="auto", 9 | api_gateway_base_path=None, 10 | custom_handlers=None, 11 | text_mime_types=None, 12 | ) 13 | ``` 14 | 15 | All arguments are optional. 16 | 17 | ## Configuring an adapter instance 18 | 19 | ::: mangum.adapter.Mangum 20 | :docstring: 21 | 22 | ## Creating an AWS Lambda handler 23 | 24 | The adapter can be used to wrap any application without referencing the underlying methods. It defines a `__call__` method that allows the class instance to be used as an AWS Lambda event handler function. 25 | 26 | ```python 27 | from mangum import Mangum 28 | from fastapi import FastAPI 29 | 30 | app = FastAPI() 31 | 32 | 33 | @app.get("/") 34 | def read_root(): 35 | return {"Hello": "World"} 36 | 37 | 38 | @app.get("/items/{item_id}") 39 | def read_item(item_id: int, q: str = None): 40 | return {"item_id": item_id, "q": q} 41 | 42 | 43 | handler = Mangum(app) 44 | ``` 45 | 46 | However, this is just one convention, you may also intercept events and construct the adapter instance separately. This may be useful if you need to implement custom event handling. The `handler` in the example above could be replaced with a function. 47 | 48 | ```python 49 | def handler(event, context): 50 | if event.get("some-key"): 51 | # Do something or return, etc. 52 | return 53 | 54 | asgi_handler = Mangum(app) 55 | response = asgi_handler(event, context) # Call the instance with the event arguments 56 | 57 | return response 58 | ``` 59 | 60 | ## Retrieving the AWS event and context 61 | 62 | The AWS Lambda handler `event` and `context` arguments are made available to an ASGI application in the ASGI connection scope. 63 | 64 | ```python 65 | scope['aws.event'] 66 | scope['aws.context'] 67 | ``` 68 | 69 | For example, if you're using FastAPI it can be retrieved from the `scope` attribute of the request object. 70 | 71 | ```python 72 | from fastapi import FastAPI 73 | from mangum import Mangum 74 | from starlette.requests import Request 75 | 76 | app = FastAPI() 77 | 78 | 79 | @app.get("/") 80 | def hello(request: Request): 81 | return {"aws_event": request.scope["aws.event"]} 82 | 83 | handler = Mangum(app) 84 | ``` 85 | -------------------------------------------------------------------------------- /docs/asgi-frameworks.md: -------------------------------------------------------------------------------- 1 | # Frameworks 2 | 3 | Mangum is intended to provide support to any [ASGI](https://asgi.readthedocs.io/en/latest/) (*Asynchronous Server Gateway Interface*) application or framework. The ["turtles all the way down"](https://simonwillison.net/2009/May/19/djng/?#turtles-all-the-way-down) principle of ASGI allows for a great deal of interoperability across many different implementations, so the adapter should "just work"* for any ASGI application or framework. 4 | 5 | * if it doesn't, then please open an [issue](https://github.com/erm/mangum/issues). :) 6 | 7 | ## Background 8 | 9 | We can think about the ASGI framework support without referencing an existing implementation. There are no framework-specific rules or dependencies in the adapter class, and all applications will be treated the same. 10 | 11 | Let's invent an API for a non-existent microframework to demonstrate things further. This could represent *any* ASGI framework application: 12 | 13 | ```python 14 | import mangum.adapter 15 | import framework 16 | from mangum import Mangum 17 | 18 | app = framework.applications.Application() 19 | 20 | 21 | @app.route("/") 22 | def endpoint(request: framework.requests.Request) -> dict: 23 | return {"hi": "there"} 24 | 25 | 26 | handler = Mangum(app) 27 | ``` 28 | 29 | None of the framework details are important here. The routing decorator, request parameter, and return value of the endpoint method could be anything. The `app` instance will be a valid `app` parameter for Mangum so long as the framework exposes an ASGI-compatible interface: 30 | 31 | ```python 32 | class Application(Protocol): 33 | async def __call__(self, scope: Scope, receive: ASGIReceive, send: ASGISend) -> None: 34 | ... 35 | ``` 36 | 37 | ### Limitations 38 | 39 | An application or framework may implement behaviour that is incompatible with the [limitations](https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-limits.html) of AWS Lambda, and there may be additional configuration required depending on a particular deployment circumstance. In some cases it is possible to work around these limitations, but these kinds of limitations should generally be dealt with outside of Mangum itself. 40 | 41 | ## Frameworks 42 | 43 | The examples on this page attempt to demonstrate a basic implementation of a particular framework (usually from official documentation) to highlight the interaction with Mangum. Specific deployment tooling, infrastructure, external dependencies, etc. are not taken into account. 44 | 45 | ### Starlette 46 | 47 | [Starlette](https://www.starlette.io/) is a lightweight ASGI framework/toolkit, which is ideal for building high performance asyncio services. 48 | 49 | Mangum uses it as a toolkit in tests. It is developed by [Encode](https://github.com/encode), a wonderful community and collection of projects that are forming the foundations of the Python async web ecosystem. 50 | 51 | Define an application: 52 | 53 | ```python 54 | from starlette.applications import Starlette 55 | from starlette.responses import JSONResponse 56 | from starlette.routing import Route 57 | from mangum import Mangum 58 | 59 | 60 | async def homepage(request): 61 | return JSONResponse({'hello': 'world'}) 62 | 63 | routes = [ 64 | Route("/", endpoint=homepage) 65 | ] 66 | 67 | app = Starlette(debug=True, routes=routes) 68 | ``` 69 | 70 | Then wrap it using Mangum: 71 | 72 | ```python 73 | handler = Mangum(app) 74 | ``` 75 | 76 | ### FastAPI 77 | 78 | [FastAPI](https://fastapi.tiangolo.com/) is a modern, fast (high-performance), web framework for building APIs with Python based on standard Python type hints. 79 | 80 | ```python 81 | from fastapi import FastAPI 82 | from mangum import Mangum 83 | 84 | app = FastAPI() 85 | 86 | 87 | @app.get("/") 88 | def read_root(): 89 | return {"Hello": "World"} 90 | 91 | 92 | @app.get("/items/{item_id}") 93 | def read_item(item_id: int, q: str = None): 94 | return {"item_id": item_id, "q": q} 95 | 96 | handler = Mangum(app) 97 | ``` 98 | 99 | ### Responder 100 | 101 | [Responder](https://responder.readthedocs.io/en/latest) is a familiar HTTP Service Framework for Python, powered by Starlette. The `static_dir` and `templates_dir` parameters must be set to none to disable Responder's automatic directory creation behaviour because AWS Lambda is a read-only file system - see the [limitations](#limitations) section for more details. 102 | 103 | ```python 104 | from mangum import Mangum 105 | import responder 106 | 107 | app = responder.API(static_dir=None, templates_dir=None) 108 | 109 | 110 | @app.route("/{greeting}") 111 | async def greet_world(req, resp, *, greeting): 112 | resp.text = f"{greeting}, world!" 113 | 114 | 115 | handler = Mangum(app) 116 | ``` 117 | 118 | The adapter usage for both FastAPI and Responder is the same as Starlette. However, this may be expected because they are built on Starlette - what about other frameworks? 119 | 120 | ### Quart 121 | 122 | [Quart](https://pgjones.gitlab.io/quart/) is a Python ASGI web microframework. It is intended to provide the easiest way to use asyncio functionality in a web context, especially with existing Flask apps. This is possible as the Quart API is a superset of the Flask API. 123 | 124 | ```python 125 | from quart import Quart 126 | from mangum import Mangum 127 | 128 | app = Quart(__name__) 129 | 130 | 131 | @app.route("/hello") 132 | async def hello(): 133 | return "hello world!" 134 | 135 | handler = Mangum(app) 136 | ``` 137 | 138 | ### Sanic 139 | 140 | [Sanic](https://github.com/huge-success/sanic) is a Python web server and web framework that's written to go fast. It allows the usage of the async/await syntax added in Python 3.5, which makes your code non-blocking and speedy. 141 | 142 | ```python 143 | from sanic import Sanic 144 | from sanic.response import json 145 | from mangum import Mangum 146 | 147 | app = Sanic() 148 | 149 | 150 | @app.route("/") 151 | async def test(request): 152 | return json({"hello": "world"}) 153 | 154 | 155 | handler = Mangum(app) 156 | ``` 157 | 158 | ### Django 159 | 160 | [Django](https://docs.djangoproject.com/) is a high-level Python Web framework that encourages rapid development and clean, pragmatic design. 161 | 162 | It started introducing ASGI support in version [3.0](https://docs.djangoproject.com/en/3.0/releases/3.0/#asgi-support). Certain async capabilities are not yet implemented and planned for future releases, however it can still be used with Mangum and other ASGI applications at the outer application level. 163 | 164 | ```python 165 | # asgi.py 166 | import os 167 | from mangum import Mangum 168 | from django.core.asgi import get_asgi_application 169 | 170 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") 171 | 172 | application = get_asgi_application() 173 | 174 | handler = Mangum(application, lifespan="off") 175 | ``` 176 | 177 | This example looks a bit different than the others because it is based on Django's standard project configuration, but the ASGI behaviour is the same. 178 | 179 | ### Channels 180 | 181 | [Channels](https://channels.readthedocs.io/en/latest/) is a project that takes Django and extends its abilities beyond HTTP - to handle WebSockets, chat protocols, IoT protocols, and more. It is the original driving force behind the ASGI specification. 182 | 183 | It currently does [not](https://github.com/django/channels/issues/1319 184 | ) support ASGI version 3, but you can convert the application from ASGI version 2 using the `guarantee_single_callable` method provided in [asgiref](https://github.com/django/asgiref). 185 | 186 | ```python 187 | # asgi.py 188 | import os 189 | import django 190 | from channels.routing import get_default_application 191 | from asgiref.compatibility import guarantee_single_callable 192 | from mangum import Mangum 193 | 194 | 195 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings") 196 | django.setup() 197 | application = get_default_application() 198 | 199 | wrapped_application = guarantee_single_callable(application) 200 | handler = Mangum(wrapped_application, lifespan="off") 201 | ``` 202 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing to Mangum 2 | 3 | Hello. Contributions to this project are highly encouraged and appreciated. This document will outline some general guidelines for how to get started. 4 | 5 | ## Contents 6 | 7 | - [Contributing to Mangum](#contributing-to-mangum) 8 | - [Contents](#contents) 9 | - [Creating a pull request](#creating-a-pull-request) 10 | - [Setting up the repository](#setting-up-the-repository) 11 | - [Developing the project locally](#developing-the-project-locally) 12 | - [Setup](#setup) 13 | - [Test](#test) 14 | - [Coverage requirements](#coverage-requirements) 15 | - [Lint](#lint) 16 | - [Code style and formatting](#code-style-and-formatting) 17 | - [Static type checking](#static-type-checking) 18 | - [Using the issue tracker](#using-the-issue-tracker) 19 | - [Technical support](#technical-support) 20 | - [Feature requests](#feature-requests) 21 | - [Thank you](#thank-you) 22 | 23 | ## Creating a pull request 24 | 25 | Non-trivial changes, especially those that could impact existing behaviour, should have an associated issue created for discussion. An issue isn't strictly required for larger changes, but it can be helpful to discuss first. 26 | 27 | Minor changes generally should not require a new issue and can be explained in the pull request description. 28 | 29 | ### Setting up the repository 30 | 31 | To create a pull request, you must first [fork](https://docs.github.com/en/free-pro-team@latest/github/collaborating-with-issues-and-pull-requests/about-forks) the repository in GitHub, then clone the fork locally. 32 | 33 | ```shell 34 | git clone git@github.com:/mangum.git 35 | ``` 36 | 37 | Then add the upstream remote to keep the forked repo in sync with the original. 38 | 39 | ```shell 40 | cd mangum 41 | git remote add upstream git://github.com/jordaneremieff/mangum.git 42 | git fetch upstream 43 | ``` 44 | 45 | Then to keep in sync with changes in the primary repository, you pull the upstream changes into your local fork. 46 | 47 | ```shell 48 | git pull upstream main 49 | ``` 50 | 51 | ## Developing the project locally 52 | 53 | There are a few scripts in place to assist with local development, the following scripts are located in the `/scripts` directory: 54 | 55 | ### Setup 56 | 57 | Running the setup script will create a local Python virtual environment. It assumes that `python3.7` is available in the path and will install the development dependencies located in `requirements.txt`. 58 | 59 | ```shell 60 | ./scripts/setup 61 | ``` 62 | 63 | Alternatively, you may create a virtual environment and install the requirements manually: 64 | 65 | ``` 66 | python -m venv venv 67 | . venv/bin/active 68 | pip install -r requirements.txt 69 | ``` 70 | 71 | This environment is used to run the tests for Python versions 3.7, 3.8, 3.9, and 3.10. 72 | 73 | ### Test 74 | 75 | The test script will run all the test cases with [PyTest](https://docs.pytest.org/en/stable/) using the path for the virtual environment created in the setup step (above). 76 | 77 | ```shell 78 | ./scripts/test 79 | ``` 80 | 81 | It also runs [Coverage](https://coverage.readthedocs.io/en/coverage-5.3/) to produce a code coverage report. 82 | 83 | #### Coverage requirements 84 | 85 | The coverage script is intended to fail under 100% test coverage, but this is not a strict requirement for contributions. Generally speaking at least one test should be included in a PR, but it is okay to use `# pragma: no cover` comments in the code to exclude specific coverage cases from the build. 86 | 87 | ### Lint 88 | 89 | The linting script will handle running [mypy](https://github.com/python/mypy) for static type checking, and [black](https://github.com/psf/black) for code formatting. 90 | 91 | ```shell 92 | ./scripts/lint 93 | ``` 94 | 95 | #### Code style and formatting 96 | 97 | Black formatting is required for all files with a maximum line-length of `88` (black's default) and double-quotes `"` are preferred over single-quotes `'`, otherwise there aren't specific style guidelines. 98 | 99 | #### Static type checking 100 | 101 | Mypy is used to handle static type checking in the build, and [type annotations](https://mypy.readthedocs.io/en/stable/cheat_sheet_py3.html) should be included when making changes or additions to the code. However, it is okay to use `# type: ignore` comments when it is unclear what type to use, or if the annotation required to pass the type checker significantly decreases readability. 102 | 103 | ## Using the issue tracker 104 | 105 | The issue [tracker](https://github.com/jordaneremieff/mangum/issues) can be used for different types of discussion, but it is mainly intended for items that are relevant to this project specifically. 106 | 107 | Here are a few things you might consider before opening a new issue: 108 | 109 | - Is this covered in the [documentation](https://mangum.fastapiexpert.com/)? 110 | 111 | - Is there already a related issue in the [tracker](https://github.com/Kludex/mangum/issues)? 112 | 113 | - Is this a problem related to Mangum itself or a third-party dependency? 114 | 115 | It may still be perfectly valid to open an issue if one or more of these is true, but thinking about these questions might help reveal an existing answer sooner. 116 | 117 | ### Technical support 118 | 119 | You may run into problems running Mangum that are related to a deployment tool (e.g. [Serverless Framework](https://www.serverless.com/)), an ASGI framework (e.g. [FastAPI](https://fastapi.tiangolo.com/)), or some other external dependency. It is okay to use the tracker to resolve these kinds of issues, but keep in mind that this project does not guaruntee support for all the features of any specific ASGI framework or external tool. 120 | 121 | **Note**: These issues will typlically be closed, but it is fine to continue discussion on a closed issue. These issues will be re-opened only if a problem is discovered in Mangum itself. 122 | 123 | ### Feature requests 124 | 125 | This project is intended to be small and focused on providing an adapter class for ASGI applications deployed in AWS Lambda. Feature requests related to this use-case will generally be considered, but larger features that increase the overall scope of Mangum are less likely to be included. 126 | 127 | If you have a large feature request, please make an issue with sufficient detail and it can be discussed. Some feature requests may end up being rejected initially and re-considered later. 128 | 129 | ## Thank you 130 | 131 | :) 132 | -------------------------------------------------------------------------------- /docs/external-links.md: -------------------------------------------------------------------------------- 1 | # External Links 2 | 3 | External links related to using Mangum. 4 | 5 | - [Deploying Python web apps as AWS Lambda functions](https://til.simonwillison.net/awslambda/asgi-mangum) is a tutorial that goes through every step involved in packaging and deploying a Python web application to AWS Lambda, including how to use Mangum to wrap an ASGI application. 6 | - [Deploy FastAPI applications to AWS Lambda](https://aminalaee.dev/posts/2022/fastapi-aws-lambda/) is a quick tutorial about how to deploy FastAPI and Starlette applications to AWS Lambda using Mangum, including also a suggested approach by AWS about how to manage larger applications. 7 | 8 | If you're interested in contributing to this page, please reference this [issue](https://github.com/jordaneremieff/mangum/issues/104) in a PR. 9 | -------------------------------------------------------------------------------- /docs/http.md: -------------------------------------------------------------------------------- 1 | # HTTP 2 | 3 | Mangum provides support for the following AWS HTTP Lambda Event Source: 4 | 5 | * [API Gateway](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-rest-api.html) 6 | ([Event Examples](https://docs.aws.amazon.com/lambda/latest/dg/services-apigateway.html)) 7 | * [HTTP Gateway](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api.html) 8 | ([Event Examples](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html)) 9 | * [Application Load Balancer (ALB)](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/introduction.html) 10 | ([Event Examples](https://docs.aws.amazon.com/lambda/latest/dg/services-alb.html)) 11 | * [CloudFront Lambda@Edge](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-at-the-edge.html) 12 | ([Event Examples](https://docs.aws.amazon.com/lambda/latest/dg/lambda-edge.html)) 13 | 14 | ```python 15 | from fastapi import FastAPI 16 | from fastapi.middleware.gzip import GZipMiddleware 17 | from mangum import Mangum 18 | 19 | app = FastAPI() 20 | app.add_middleware(GZipMiddleware, minimum_size=1000) 21 | 22 | 23 | @app.get("/") 24 | async def main(): 25 | return "somebigcontent" 26 | 27 | handler = Mangum(app, TEXT_MIME_TYPES=["application/vnd.some.type"]) 28 | ``` 29 | 30 | ## Configuring binary responses 31 | 32 | Binary responses are determined using the `Content-Type` and `Content-Encoding` headers from the event request and a list of text MIME types. 33 | 34 | ### Text MIME types 35 | 36 | By default, all response data will be [base64 encoded](https://docs.python.org/3/library/base64.html#base64.b64encode) and include `isBase64Encoded=True` in the response ***except*** the following MIME types: 37 | 38 | - `application/json` 39 | - `application/javascript` 40 | - `application/xml` 41 | - `application/vnd.api+json` 42 | - `application/vnd.oai.openapi` 43 | 44 | Additionally, any `Content-Type` header prefixed with `text/` is automatically excluded. 45 | 46 | ### Compression 47 | 48 | If the `Content-Encoding` header is set to `gzip` or `br`, then a binary response will be returned regardless of MIME type. 49 | 50 | ## State machine 51 | 52 | The `HTTPCycle` is used by the adapter to communicate message events between the application and AWS. It is a state machine that handles the entire ASGI request and response cycle. 53 | 54 | ### HTTPCycle 55 | 56 | ::: mangum.protocols.http.HTTPCycle 57 | :docstring: 58 | :members: run receive send 59 | 60 | ### HTTPCycleState 61 | 62 | ::: mangum.protocols.http.HTTPCycleState 63 | :docstring: 64 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Mangum 2 | 3 | 4 | Package version 5 | 6 | PyPI - Python Version 7 | 8 | Mangum is an adapter for running [ASGI](https://asgi.readthedocs.io/en/latest/) applications in AWS Lambda to handle Function URL, API Gateway, ALB, and Lambda@Edge events. 9 | 10 | ***Documentation***: [https://mangum.fastapiexpert.com/]() 11 | 12 | ## Features 13 | 14 | - Event handlers for API Gateway [HTTP](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api.html) and [REST](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-rest-api.html) APIs, [Application Load Balancer](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/lambda-functions.html), [Function URLs](https://docs.aws.amazon.com/lambda/latest/dg/lambda-urls.html), and [CloudFront Lambda@Edge](https://docs.aws.amazon.com/lambda/latest/dg/lambda-edge.html). 15 | 16 | - Compatibility with ASGI application frameworks, such as [Starlette](https://www.starlette.io/), [FastAPI](https://fastapi.tiangolo.com/), [Quart](https://pgjones.gitlab.io/quart/) and [Django](https://www.djangoproject.com/). 17 | 18 | - Support for binary media types and payload compression in API Gateway using GZip or Brotli. 19 | 20 | - Works with existing deployment and configuration tools, including [Serverless Framework](https://www.serverless.com/) and [AWS SAM](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/what-is-sam.html). 21 | 22 | - Startup and shutdown [lifespan](https://asgi.readthedocs.io/en/latest/specs/lifespan.html) events. 23 | 24 | ## Installation 25 | 26 | ```shell 27 | pip install mangum 28 | ``` 29 | 30 | ## Example 31 | 32 | ```python 33 | from mangum import Mangum 34 | 35 | async def app(scope, receive, send): 36 | await send( 37 | { 38 | "type": "http.response.start", 39 | "status": 200, 40 | "headers": [[b"content-type", b"text/plain; charset=utf-8"]], 41 | } 42 | ) 43 | await send({"type": "http.response.body", "body": b"Hello, world!"}) 44 | 45 | 46 | handler = Mangum(app, lifespan="off") 47 | ``` 48 | 49 | Or using a framework: 50 | 51 | ```python 52 | from fastapi import FastAPI 53 | from mangum import Mangum 54 | 55 | app = FastAPI() 56 | 57 | 58 | @app.get("/") 59 | def read_root(): 60 | return {"Hello": "World"} 61 | 62 | 63 | @app.get("/items/{item_id}") 64 | def read_item(item_id: int, q: str = None): 65 | return {"item_id": item_id, "q": q} 66 | 67 | handler = Mangum(app, lifespan="off") 68 | ``` 69 | -------------------------------------------------------------------------------- /docs/lifespan.md: -------------------------------------------------------------------------------- 1 | # Lifespan 2 | 3 | Mangum supports the ASGI [Lifespan](https://asgi.readthedocs.io/en/latest/specs/lifespan.html) protocol. This allows applications to define lifespan startup and shutdown event handlers. 4 | 5 | ```python 6 | from mangum import Mangum 7 | from fastapi import FastAPI 8 | 9 | app = FastAPI() 10 | 11 | 12 | @app.on_event("startup") 13 | async def startup_event(): 14 | pass 15 | 16 | 17 | @app.on_event("shutdown") 18 | async def shutdown_event(): 19 | pass 20 | 21 | 22 | @app.get("/") 23 | def read_root(): 24 | return {"Hello": "World"} 25 | 26 | handler = Mangum(app, lifespan="auto") 27 | ``` 28 | 29 | ## Configuring Lifespan events 30 | 31 | Lifespan support is automatically determined unless explicitly turned on or off. A string value option is used to configure lifespan support, the choices are `auto`, `on`, and `off`. 32 | 33 | ### Options 34 | 35 | - **auto** 36 | 37 | Application support for lifespan **is inferred** using the state transitions. Any error that occurs during startup will be logged and the ASGI application cycle will continue unless a `lifespan.startup.failed` event is sent. 38 | 39 | - **on** 40 | 41 | Application support for lifespan **is explicit**. Any error that occurs during startup will be raised and a 500 response will be returned. 42 | 43 | - **off** 44 | 45 | Application support for lifespan **is ignored**. The application will not enter the lifespan cycle context. 46 | 47 | Defaults to `auto`. 48 | 49 | ## State machine 50 | 51 | The `LifespanCycle` is a state machine that handles ASGI `lifespan` events intended to run before and after HTTP requests are handled. 52 | 53 | ### LifespanCycle 54 | 55 | ::: mangum.protocols.lifespan.LifespanCycle 56 | :docstring: 57 | :members: run receive send startup shutdown 58 | 59 | #### Context manager 60 | 61 | Unlike the `HTTPCycle` class, the `LifespanCycle` is also used as a context manager in the adapter class. If lifespan support is turned off, then the application never enters the lifespan cycle context. 62 | 63 | ```python 64 | with ExitStack() as stack: 65 | # Ignore lifespan events entirely if the `lifespan` setting is `off`. 66 | if self.lifespan in ("auto", "on"): 67 | asgi_cycle: typing.ContextManager = LifespanCycle( 68 | self.app, self.lifespan 69 | ) 70 | stack.enter_context(asgi_cycle) 71 | ``` 72 | 73 | The magic methods `__enter__` and `__exit__` handle running the async tasks that perform startup and shutdown functions. 74 | 75 | ```python 76 | def __enter__(self) -> None: 77 | """ 78 | Runs the event loop for application startup. 79 | """ 80 | self.loop.create_task(self.run()) 81 | self.loop.run_until_complete(self.startup()) 82 | 83 | def __exit__( 84 | self, 85 | exc_type: typing.Optional[typing.Type[BaseException]], 86 | exc_value: typing.Optional[BaseException], 87 | traceback: typing.Optional[types.TracebackType], 88 | ) -> None: 89 | """ 90 | Runs the event loop for application shutdown. 91 | """ 92 | self.loop.run_until_complete(self.shutdown()) 93 | ``` 94 | 95 | ### LifespanCycleState 96 | 97 | ::: mangum.protocols.lifespan.LifespanCycleState 98 | :docstring: 99 | 100 | -------------------------------------------------------------------------------- /mangum/__init__.py: -------------------------------------------------------------------------------- 1 | from mangum.adapter import Mangum 2 | 3 | __all__ = ["Mangum"] 4 | -------------------------------------------------------------------------------- /mangum/adapter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from contextlib import ExitStack 5 | from itertools import chain 6 | from typing import Any 7 | 8 | from mangum.exceptions import ConfigurationError 9 | from mangum.handlers import ALB, APIGateway, HTTPGateway, LambdaAtEdge 10 | from mangum.protocols import HTTPCycle, LifespanCycle 11 | from mangum.types import ASGI, LambdaConfig, LambdaContext, LambdaEvent, LambdaHandler, LifespanMode 12 | 13 | logger = logging.getLogger("mangum") 14 | 15 | HANDLERS: list[type[LambdaHandler]] = [ALB, HTTPGateway, APIGateway, LambdaAtEdge] 16 | 17 | DEFAULT_TEXT_MIME_TYPES: list[str] = [ 18 | "text/", 19 | "application/json", 20 | "application/javascript", 21 | "application/xml", 22 | "application/vnd.api+json", 23 | "application/vnd.oai.openapi", 24 | ] 25 | 26 | 27 | class Mangum: 28 | def __init__( 29 | self, 30 | app: ASGI, 31 | lifespan: LifespanMode = "auto", 32 | api_gateway_base_path: str = "/", 33 | custom_handlers: list[type[LambdaHandler]] | None = None, 34 | text_mime_types: list[str] | None = None, 35 | exclude_headers: list[str] | None = None, 36 | ) -> None: 37 | if lifespan not in ("auto", "on", "off"): 38 | raise ConfigurationError("Invalid argument supplied for `lifespan`. Choices are: auto|on|off") 39 | 40 | self.app = app 41 | self.lifespan = lifespan 42 | self.custom_handlers = custom_handlers or [] 43 | exclude_headers = exclude_headers or [] 44 | self.config = LambdaConfig( 45 | api_gateway_base_path=api_gateway_base_path or "/", 46 | text_mime_types=text_mime_types or [*DEFAULT_TEXT_MIME_TYPES], 47 | exclude_headers=[header.lower() for header in exclude_headers], 48 | ) 49 | 50 | def infer(self, event: LambdaEvent, context: LambdaContext) -> LambdaHandler: 51 | for handler_cls in chain(self.custom_handlers, HANDLERS): 52 | if handler_cls.infer(event, context, self.config): 53 | return handler_cls(event, context, self.config) 54 | raise RuntimeError( # pragma: no cover 55 | "The adapter was unable to infer a handler to use for the event. This " 56 | "is likely related to how the Lambda function was invoked. (Are you " 57 | "testing locally? Make sure the request payload is valid for a " 58 | "supported handler.)" 59 | ) 60 | 61 | def __call__(self, event: LambdaEvent, context: LambdaContext) -> dict[str, Any]: 62 | handler = self.infer(event, context) 63 | scope = handler.scope 64 | with ExitStack() as stack: 65 | if self.lifespan in ("auto", "on"): 66 | lifespan_cycle = LifespanCycle(self.app, self.lifespan) 67 | stack.enter_context(lifespan_cycle) 68 | scope.update({"state": lifespan_cycle.lifespan_state.copy()}) 69 | 70 | http_cycle = HTTPCycle(scope, handler.body) 71 | http_response = http_cycle(self.app) 72 | 73 | return handler(http_response) 74 | 75 | assert False, "unreachable" # pragma: no cover 76 | -------------------------------------------------------------------------------- /mangum/exceptions.py: -------------------------------------------------------------------------------- 1 | class LifespanFailure(Exception): 2 | """Raise when a lifespan failure event is sent by an application.""" 3 | 4 | 5 | class LifespanUnsupported(Exception): 6 | """Raise when lifespan events are not supported by an application.""" 7 | 8 | 9 | class UnexpectedMessage(Exception): 10 | """Raise when an unexpected message type is received during an ASGI cycle.""" 11 | 12 | 13 | class ConfigurationError(Exception): 14 | """Raise when an error occurs parsing configuration.""" 15 | -------------------------------------------------------------------------------- /mangum/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | from mangum.handlers.alb import ALB 2 | from mangum.handlers.api_gateway import APIGateway, HTTPGateway 3 | from mangum.handlers.lambda_at_edge import LambdaAtEdge 4 | 5 | __all__ = ["APIGateway", "HTTPGateway", "ALB", "LambdaAtEdge"] 6 | -------------------------------------------------------------------------------- /mangum/handlers/alb.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from itertools import islice 4 | from typing import Any, Generator 5 | from urllib.parse import unquote, unquote_plus, urlencode 6 | 7 | from mangum.handlers.utils import ( 8 | get_server_and_port, 9 | handle_base64_response_body, 10 | handle_exclude_headers, 11 | maybe_encode_body, 12 | ) 13 | from mangum.types import ( 14 | LambdaConfig, 15 | LambdaContext, 16 | LambdaEvent, 17 | QueryParams, 18 | Response, 19 | Scope, 20 | ) 21 | 22 | 23 | def all_casings(input_string: str) -> Generator[str, None, None]: 24 | """ 25 | Permute all casings of a given string. 26 | A pretty algoritm, via @Amber 27 | http://stackoverflow.com/questions/6792803/finding-all-possible-case-permutations-in-python 28 | """ 29 | if not input_string: 30 | yield "" 31 | else: 32 | first = input_string[:1] 33 | if first.lower() == first.upper(): 34 | for sub_casing in all_casings(input_string[1:]): 35 | yield first + sub_casing 36 | else: 37 | for sub_casing in all_casings(input_string[1:]): 38 | yield first.lower() + sub_casing 39 | yield first.upper() + sub_casing 40 | 41 | 42 | def case_mutated_headers(multi_value_headers: dict[str, list[str]]) -> dict[str, str]: 43 | """Create str/str key/value headers, with duplicate keys case mutated.""" 44 | headers: dict[str, str] = {} 45 | for key, values in multi_value_headers.items(): 46 | if len(values) > 0: 47 | casings = list(islice(all_casings(key), len(values))) 48 | for value, cased_key in zip(values, casings): 49 | headers[cased_key] = value 50 | return headers 51 | 52 | 53 | def encode_query_string_for_alb(params: QueryParams) -> bytes: 54 | """Encode the query string parameters for the ALB event. The parameters must be 55 | decoded and then encoded again to prevent double encoding. 56 | 57 | According to the docs: 58 | 59 | "If the query parameters are URL-encoded, the load balancer does not decode 60 | "them. You must decode them in your Lambda function." 61 | """ 62 | params = { 63 | unquote_plus(key): ( 64 | unquote_plus(value) if isinstance(value, str) else tuple(unquote_plus(element) for element in value) 65 | ) 66 | for key, value in params.items() 67 | } 68 | query_string = urlencode(params, doseq=True).encode() 69 | 70 | return query_string 71 | 72 | 73 | def transform_headers(event: LambdaEvent) -> list[tuple[bytes, bytes]]: 74 | headers: list[tuple[bytes, bytes]] = [] 75 | if "multiValueHeaders" in event: 76 | for k, v in event["multiValueHeaders"].items(): 77 | for inner_v in v: 78 | headers.append((k.lower().encode(), inner_v.encode())) 79 | else: 80 | for k, v in event["headers"].items(): 81 | headers.append((k.lower().encode(), v.encode())) 82 | 83 | return headers 84 | 85 | 86 | class ALB: 87 | @classmethod 88 | def infer(cls, event: LambdaEvent, context: LambdaContext, config: LambdaConfig) -> bool: 89 | return "requestContext" in event and "elb" in event["requestContext"] 90 | 91 | def __init__(self, event: LambdaEvent, context: LambdaContext, config: LambdaConfig) -> None: 92 | self.event = event 93 | self.context = context 94 | self.config = config 95 | 96 | @property 97 | def body(self) -> bytes: 98 | return maybe_encode_body( 99 | self.event.get("body", b""), 100 | is_base64=self.event.get("isBase64Encoded", False), 101 | ) 102 | 103 | @property 104 | def scope(self) -> Scope: 105 | headers = transform_headers(self.event) 106 | list_headers = [list(x) for x in headers] 107 | # Unique headers. If there are duplicates, it will use the last defined. 108 | uq_headers = {k.decode(): v.decode() for k, v in headers} 109 | source_ip = uq_headers.get("x-forwarded-for", "") 110 | path = unquote(self.event["path"]) if self.event["path"] else "/" 111 | http_method = self.event["httpMethod"] 112 | 113 | params = self.event.get( 114 | "multiValueQueryStringParameters", 115 | self.event.get("queryStringParameters", {}), 116 | ) 117 | if not params: 118 | query_string = b"" 119 | else: 120 | query_string = encode_query_string_for_alb(params) 121 | 122 | server = get_server_and_port(uq_headers) 123 | client = (source_ip, 0) 124 | 125 | scope: Scope = { 126 | "type": "http", 127 | "method": http_method, 128 | "http_version": "1.1", 129 | "headers": list_headers, 130 | "path": path, 131 | "raw_path": None, 132 | "root_path": "", 133 | "scheme": uq_headers.get("x-forwarded-proto", "https"), 134 | "query_string": query_string, 135 | "server": server, 136 | "client": client, 137 | "asgi": {"version": "3.0", "spec_version": "2.0"}, 138 | "aws.event": self.event, 139 | "aws.context": self.context, 140 | } 141 | 142 | return scope 143 | 144 | def __call__(self, response: Response) -> dict[str, Any]: 145 | multi_value_headers: dict[str, list[str]] = {} 146 | for key, value in response["headers"]: 147 | lower_key = key.decode().lower() 148 | if lower_key not in multi_value_headers: 149 | multi_value_headers[lower_key] = [] 150 | multi_value_headers[lower_key].append(value.decode()) 151 | 152 | finalized_headers = case_mutated_headers(multi_value_headers) 153 | finalized_body, is_base64_encoded = handle_base64_response_body( 154 | response["body"], finalized_headers, self.config["text_mime_types"] 155 | ) 156 | 157 | out = { 158 | "statusCode": response["status"], 159 | "body": finalized_body, 160 | "isBase64Encoded": is_base64_encoded, 161 | } 162 | 163 | # You must use multiValueHeaders if you have enabled multi-value headers and 164 | # headers otherwise. 165 | multi_value_headers_enabled = "multiValueHeaders" in self.scope["aws.event"] 166 | if multi_value_headers_enabled: 167 | out["multiValueHeaders"] = handle_exclude_headers(multi_value_headers, self.config) 168 | else: 169 | out["headers"] = handle_exclude_headers(finalized_headers, self.config) 170 | 171 | return out 172 | -------------------------------------------------------------------------------- /mangum/handlers/api_gateway.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | from urllib.parse import urlencode 5 | 6 | from mangum.handlers.utils import ( 7 | get_server_and_port, 8 | handle_base64_response_body, 9 | handle_exclude_headers, 10 | handle_multi_value_headers, 11 | maybe_encode_body, 12 | strip_api_gateway_path, 13 | ) 14 | from mangum.types import ( 15 | Headers, 16 | LambdaConfig, 17 | LambdaContext, 18 | LambdaEvent, 19 | QueryParams, 20 | Response, 21 | Scope, 22 | ) 23 | 24 | 25 | def _encode_query_string_for_apigw(event: LambdaEvent) -> bytes: 26 | params: QueryParams = event.get("multiValueQueryStringParameters", {}) 27 | if not params: 28 | params = event.get("queryStringParameters", {}) 29 | if not params: 30 | return b"" 31 | 32 | return urlencode(params, doseq=True).encode() 33 | 34 | 35 | def _handle_multi_value_headers_for_request(event: LambdaEvent) -> dict[str, str]: 36 | headers = event.get("headers", {}) or {} 37 | headers = {k.lower(): v for k, v in headers.items()} 38 | if event.get("multiValueHeaders"): 39 | headers.update( 40 | { 41 | k.lower(): ", ".join(v) if isinstance(v, list) else "" 42 | for k, v in event.get("multiValueHeaders", {}).items() 43 | } 44 | ) 45 | 46 | return headers 47 | 48 | 49 | def _combine_headers_v2( 50 | input_headers: Headers, 51 | ) -> tuple[dict[str, str], list[str]]: 52 | output_headers: dict[str, str] = {} 53 | cookies: list[str] = [] 54 | for key, value in input_headers: 55 | normalized_key: str = key.decode().lower() 56 | normalized_value: str = value.decode() 57 | if normalized_key == "set-cookie": 58 | cookies.append(normalized_value) 59 | else: 60 | if normalized_key in output_headers: 61 | normalized_value = f"{output_headers[normalized_key]},{normalized_value}" 62 | output_headers[normalized_key] = normalized_value 63 | 64 | return output_headers, cookies 65 | 66 | 67 | class APIGateway: 68 | @classmethod 69 | def infer(cls, event: LambdaEvent, context: LambdaContext, config: LambdaConfig) -> bool: 70 | return "resource" in event and "requestContext" in event 71 | 72 | def __init__(self, event: LambdaEvent, context: LambdaContext, config: LambdaConfig) -> None: 73 | self.event = event 74 | self.context = context 75 | self.config = config 76 | 77 | @property 78 | def body(self) -> bytes: 79 | return maybe_encode_body( 80 | self.event.get("body", b""), 81 | is_base64=self.event.get("isBase64Encoded", False), 82 | ) 83 | 84 | @property 85 | def scope(self) -> Scope: 86 | headers = _handle_multi_value_headers_for_request(self.event) 87 | return { 88 | "type": "http", 89 | "http_version": "1.1", 90 | "method": self.event["httpMethod"], 91 | "headers": [[k.encode(), v.encode()] for k, v in headers.items()], 92 | "path": strip_api_gateway_path( 93 | self.event["path"], 94 | api_gateway_base_path=self.config["api_gateway_base_path"], 95 | ), 96 | "raw_path": None, 97 | "root_path": "", 98 | "scheme": headers.get("x-forwarded-proto", "https"), 99 | "query_string": _encode_query_string_for_apigw(self.event), 100 | "server": get_server_and_port(headers), 101 | "client": ( 102 | self.event["requestContext"].get("identity", {}).get("sourceIp"), 103 | 0, 104 | ), 105 | "asgi": {"version": "3.0", "spec_version": "2.0"}, 106 | "aws.event": self.event, 107 | "aws.context": self.context, 108 | } 109 | 110 | def __call__(self, response: Response) -> dict[str, Any]: 111 | finalized_headers, multi_value_headers = handle_multi_value_headers(response["headers"]) 112 | finalized_body, is_base64_encoded = handle_base64_response_body( 113 | response["body"], finalized_headers, self.config["text_mime_types"] 114 | ) 115 | 116 | return { 117 | "statusCode": response["status"], 118 | "headers": handle_exclude_headers(finalized_headers, self.config), 119 | "multiValueHeaders": handle_exclude_headers(multi_value_headers, self.config), 120 | "body": finalized_body, 121 | "isBase64Encoded": is_base64_encoded, 122 | } 123 | 124 | 125 | class HTTPGateway: 126 | @classmethod 127 | def infer(cls, event: LambdaEvent, context: LambdaContext, config: LambdaConfig) -> bool: 128 | return "version" in event and "requestContext" in event 129 | 130 | def __init__(self, event: LambdaEvent, context: LambdaContext, config: LambdaConfig) -> None: 131 | self.event = event 132 | self.context = context 133 | self.config = config 134 | 135 | @property 136 | def body(self) -> bytes: 137 | return maybe_encode_body( 138 | self.event.get("body", b""), 139 | is_base64=self.event.get("isBase64Encoded", False), 140 | ) 141 | 142 | @property 143 | def scope(self) -> Scope: 144 | request_context = self.event["requestContext"] 145 | event_version = self.event["version"] 146 | 147 | # API Gateway v2 148 | if event_version == "2.0": 149 | headers = {k.lower(): v for k, v in self.event.get("headers", {}).items()} 150 | source_ip = request_context["http"]["sourceIp"] 151 | path = request_context["http"]["path"] 152 | http_method = request_context["http"]["method"] 153 | query_string = self.event.get("rawQueryString", "").encode() 154 | 155 | if self.event.get("cookies"): 156 | headers["cookie"] = "; ".join(self.event.get("cookies", [])) 157 | 158 | # API Gateway v1 159 | else: 160 | headers = _handle_multi_value_headers_for_request(self.event) 161 | source_ip = request_context.get("identity", {}).get("sourceIp") 162 | path = self.event["path"] 163 | http_method = self.event["httpMethod"] 164 | query_string = _encode_query_string_for_apigw(self.event) 165 | 166 | path = strip_api_gateway_path( 167 | path, 168 | api_gateway_base_path=self.config["api_gateway_base_path"], 169 | ) 170 | server = get_server_and_port(headers) 171 | client = (source_ip, 0) 172 | 173 | return { 174 | "type": "http", 175 | "method": http_method, 176 | "http_version": "1.1", 177 | "headers": [[k.encode(), v.encode()] for k, v in headers.items()], 178 | "path": path, 179 | "raw_path": None, 180 | "root_path": "", 181 | "scheme": headers.get("x-forwarded-proto", "https"), 182 | "query_string": query_string, 183 | "server": server, 184 | "client": client, 185 | "asgi": {"version": "3.0", "spec_version": "2.0"}, 186 | "aws.event": self.event, 187 | "aws.context": self.context, 188 | } 189 | 190 | def __call__(self, response: Response) -> dict[str, Any]: 191 | if self.scope["aws.event"]["version"] == "2.0": 192 | finalized_headers, cookies = _combine_headers_v2(response["headers"]) 193 | 194 | if "content-type" not in finalized_headers and response["body"] is not None: 195 | finalized_headers["content-type"] = "application/json" 196 | 197 | finalized_body, is_base64_encoded = handle_base64_response_body( 198 | response["body"], finalized_headers, self.config["text_mime_types"] 199 | ) 200 | response_out = { 201 | "statusCode": response["status"], 202 | "body": finalized_body, 203 | "headers": finalized_headers or None, 204 | "cookies": cookies or None, 205 | "isBase64Encoded": is_base64_encoded, 206 | } 207 | return {key: value for key, value in response_out.items() if value is not None} 208 | 209 | finalized_headers, multi_value_headers = handle_multi_value_headers(response["headers"]) 210 | finalized_body, is_base64_encoded = handle_base64_response_body( 211 | response["body"], finalized_headers, self.config["text_mime_types"] 212 | ) 213 | return { 214 | "statusCode": response["status"], 215 | "headers": finalized_headers, 216 | "multiValueHeaders": multi_value_headers, 217 | "body": finalized_body, 218 | "isBase64Encoded": is_base64_encoded, 219 | } 220 | -------------------------------------------------------------------------------- /mangum/handlers/lambda_at_edge.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | 5 | from mangum.handlers.utils import ( 6 | handle_base64_response_body, 7 | handle_exclude_headers, 8 | handle_multi_value_headers, 9 | maybe_encode_body, 10 | ) 11 | from mangum.types import LambdaConfig, LambdaContext, LambdaEvent, Response, Scope 12 | 13 | 14 | class LambdaAtEdge: 15 | @classmethod 16 | def infer(cls, event: LambdaEvent, context: LambdaContext, config: LambdaConfig) -> bool: 17 | return "Records" in event and len(event["Records"]) > 0 and "cf" in event["Records"][0] 18 | 19 | # FIXME: Since this is the last in the chain it doesn't get coverage by default, 20 | # # just ignoring it for now. 21 | # return None # pragma: nocover 22 | 23 | def __init__(self, event: LambdaEvent, context: LambdaContext, config: LambdaConfig) -> None: 24 | self.event = event 25 | self.context = context 26 | self.config = config 27 | 28 | @property 29 | def body(self) -> bytes: 30 | cf_request_body = self.event["Records"][0]["cf"]["request"].get("body", {}) 31 | return maybe_encode_body( 32 | cf_request_body.get("data"), 33 | is_base64=cf_request_body.get("encoding", "") == "base64", 34 | ) 35 | 36 | @property 37 | def scope(self) -> Scope: 38 | cf_request = self.event["Records"][0]["cf"]["request"] 39 | scheme_header = cf_request["headers"].get("cloudfront-forwarded-proto", [{}]) 40 | scheme = scheme_header[0].get("value", "https") 41 | host_header = cf_request["headers"].get("host", [{}]) 42 | server_name = host_header[0].get("value", "mangum") 43 | if ":" not in server_name: 44 | forwarded_port_header = cf_request["headers"].get("x-forwarded-port", [{}]) 45 | server_port = forwarded_port_header[0].get("value", 80) 46 | else: 47 | server_name, server_port = server_name.split(":") # pragma: no cover 48 | 49 | server = (server_name, int(server_port)) 50 | source_ip = cf_request["clientIp"] 51 | client = (source_ip, 0) 52 | http_method = cf_request["method"] 53 | 54 | return { 55 | "type": "http", 56 | "method": http_method, 57 | "http_version": "1.1", 58 | "headers": [[k.encode(), v[0]["value"].encode()] for k, v in cf_request["headers"].items()], 59 | "path": cf_request["uri"], 60 | "raw_path": None, 61 | "root_path": "", 62 | "scheme": scheme, 63 | "query_string": cf_request["querystring"].encode(), 64 | "server": server, 65 | "client": client, 66 | "asgi": {"version": "3.0", "spec_version": "2.0"}, 67 | "aws.event": self.event, 68 | "aws.context": self.context, 69 | } 70 | 71 | def __call__(self, response: Response) -> dict[str, Any]: 72 | multi_value_headers, _ = handle_multi_value_headers(response["headers"]) 73 | response_body, is_base64_encoded = handle_base64_response_body( 74 | response["body"], multi_value_headers, self.config["text_mime_types"] 75 | ) 76 | finalized_headers: dict[str, list[dict[str, str]]] = { 77 | key.decode().lower(): [{"key": key.decode().lower(), "value": val.decode()}] 78 | for key, val in response["headers"] 79 | } 80 | 81 | return { 82 | "status": response["status"], 83 | "headers": handle_exclude_headers(finalized_headers, self.config), 84 | "body": response_body, 85 | "isBase64Encoded": is_base64_encoded, 86 | } 87 | -------------------------------------------------------------------------------- /mangum/handlers/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import base64 4 | from typing import Any 5 | from urllib.parse import unquote 6 | 7 | from mangum.types import Headers, LambdaConfig 8 | 9 | 10 | def maybe_encode_body(body: str | bytes, *, is_base64: bool) -> bytes: 11 | body = body or b"" 12 | if is_base64: 13 | body = base64.b64decode(body) 14 | elif not isinstance(body, bytes): 15 | body = body.encode() 16 | 17 | return body 18 | 19 | 20 | def get_server_and_port(headers: dict[str, Any]) -> tuple[str, int]: 21 | server_name = headers.get("host", "mangum") 22 | if ":" not in server_name: 23 | server_port = headers.get("x-forwarded-port", 80) 24 | else: 25 | server_name, server_port = server_name.split(":") # pragma: no cover 26 | server = (server_name, int(server_port)) 27 | 28 | return server 29 | 30 | 31 | def strip_api_gateway_path(path: str, *, api_gateway_base_path: str) -> str: 32 | if not path: 33 | return "/" 34 | 35 | if api_gateway_base_path and api_gateway_base_path != "/": 36 | if not api_gateway_base_path.startswith("/"): 37 | api_gateway_base_path = f"/{api_gateway_base_path}" 38 | if path.startswith(api_gateway_base_path): 39 | path = path[len(api_gateway_base_path) :] 40 | 41 | return unquote(path) 42 | 43 | 44 | def handle_multi_value_headers( 45 | response_headers: Headers, 46 | ) -> tuple[dict[str, str], dict[str, list[str]]]: 47 | headers: dict[str, str] = {} 48 | multi_value_headers: dict[str, list[str]] = {} 49 | for key, value in response_headers: 50 | lower_key = key.decode().lower() 51 | if lower_key in multi_value_headers: 52 | multi_value_headers[lower_key].append(value.decode()) 53 | elif lower_key in headers: 54 | # Move existing to multi_value_headers and append current 55 | multi_value_headers[lower_key] = [ 56 | headers[lower_key], 57 | value.decode(), 58 | ] 59 | del headers[lower_key] 60 | else: 61 | headers[lower_key] = value.decode() 62 | return headers, multi_value_headers 63 | 64 | 65 | def handle_base64_response_body( 66 | body: bytes, 67 | headers: dict[str, str], 68 | text_mime_types: list[str], 69 | ) -> tuple[str, bool]: 70 | is_base64_encoded = False 71 | output_body = "" 72 | if body != b"": 73 | for text_mime_type in text_mime_types: 74 | if text_mime_type in headers.get("content-type", ""): 75 | try: 76 | output_body = body.decode() 77 | except UnicodeDecodeError: 78 | output_body = base64.b64encode(body).decode() 79 | is_base64_encoded = True 80 | break 81 | else: 82 | output_body = base64.b64encode(body).decode() 83 | is_base64_encoded = True 84 | 85 | return output_body, is_base64_encoded 86 | 87 | 88 | def handle_exclude_headers(headers: dict[str, Any], config: LambdaConfig) -> dict[str, Any]: 89 | finalized_headers = {} 90 | for header_key, header_value in headers.items(): 91 | if header_key in config["exclude_headers"]: 92 | continue 93 | finalized_headers[header_key] = header_value 94 | 95 | return finalized_headers 96 | -------------------------------------------------------------------------------- /mangum/protocols/__init__.py: -------------------------------------------------------------------------------- 1 | from .http import HTTPCycle 2 | from .lifespan import LifespanCycle, LifespanCycleState 3 | 4 | __all__ = ["HTTPCycle", "LifespanCycleState", "LifespanCycle"] 5 | -------------------------------------------------------------------------------- /mangum/protocols/http.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import enum 3 | import logging 4 | from io import BytesIO 5 | 6 | from mangum.exceptions import UnexpectedMessage 7 | from mangum.types import ASGI, Message, Response, Scope 8 | 9 | 10 | class HTTPCycleState(enum.Enum): 11 | """ 12 | The state of the ASGI `http` connection. 13 | * **REQUEST** - Initial state. The ASGI application instance will be run with the 14 | connection scope containing the `http` type. 15 | * **RESPONSE** - The `http.response.start` event has been sent by the application. 16 | The next expected message is the `http.response.body` event, containing the body 17 | content. An application may pass the `more_body` argument to send content in chunks, 18 | however content will always be returned in a single response, never streamed. 19 | * **COMPLETE** - The body content from the ASGI application has been completely 20 | read. A disconnect event will be sent to the application, and the response will 21 | be returned. 22 | """ 23 | 24 | REQUEST = enum.auto() 25 | RESPONSE = enum.auto() 26 | COMPLETE = enum.auto() 27 | 28 | 29 | class HTTPCycle: 30 | def __init__(self, scope: Scope, body: bytes) -> None: 31 | self.scope = scope 32 | self.buffer = BytesIO() 33 | self.state = HTTPCycleState.REQUEST 34 | self.logger = logging.getLogger("mangum.http") 35 | self.app_queue: asyncio.Queue[Message] = asyncio.Queue() 36 | self.app_queue.put_nowait( 37 | { 38 | "type": "http.request", 39 | "body": body, 40 | "more_body": False, 41 | } 42 | ) 43 | 44 | def __call__(self, app: ASGI) -> Response: 45 | asgi_instance = self.run(app) 46 | loop = asyncio.get_event_loop() 47 | asgi_task = loop.create_task(asgi_instance) 48 | loop.run_until_complete(asgi_task) 49 | 50 | return { 51 | "status": self.status, 52 | "headers": self.headers, 53 | "body": self.body, 54 | } 55 | 56 | async def run(self, app: ASGI) -> None: 57 | try: 58 | await app(self.scope, self.receive, self.send) 59 | except BaseException: 60 | self.logger.exception("An error occurred running the application.") 61 | if self.state is HTTPCycleState.REQUEST: 62 | await self.send( 63 | { 64 | "type": "http.response.start", 65 | "status": 500, 66 | "headers": [[b"content-type", b"text/plain; charset=utf-8"]], 67 | } 68 | ) 69 | await self.send( 70 | { 71 | "type": "http.response.body", 72 | "body": b"Internal Server Error", 73 | "more_body": False, 74 | } 75 | ) 76 | elif self.state is not HTTPCycleState.COMPLETE: 77 | self.status = 500 78 | self.body = b"Internal Server Error" 79 | self.headers = [[b"content-type", b"text/plain; charset=utf-8"]] 80 | 81 | async def receive(self) -> Message: 82 | return await self.app_queue.get() # pragma: no cover 83 | 84 | async def send(self, message: Message) -> None: 85 | if self.state is HTTPCycleState.REQUEST and message["type"] == "http.response.start": 86 | self.status = message["status"] 87 | self.headers = message.get("headers", []) 88 | self.state = HTTPCycleState.RESPONSE 89 | elif self.state is HTTPCycleState.RESPONSE and message["type"] == "http.response.body": 90 | body = message.get("body", b"") 91 | more_body = message.get("more_body", False) 92 | self.buffer.write(body) 93 | if not more_body: 94 | self.body = self.buffer.getvalue() 95 | self.buffer.close() 96 | 97 | self.state = HTTPCycleState.COMPLETE 98 | await self.app_queue.put({"type": "http.disconnect"}) 99 | 100 | self.logger.info( 101 | "%s %s %s", 102 | self.scope["method"], 103 | self.scope["path"], 104 | self.status, 105 | ) 106 | else: 107 | raise UnexpectedMessage(f"Unexpected {message['type']}") 108 | -------------------------------------------------------------------------------- /mangum/protocols/lifespan.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import enum 5 | import logging 6 | from types import TracebackType 7 | from typing import Any 8 | 9 | from mangum.exceptions import LifespanFailure, LifespanUnsupported, UnexpectedMessage 10 | from mangum.types import ASGI, LifespanMode, Message 11 | 12 | 13 | class LifespanCycleState(enum.Enum): 14 | """ 15 | The state of the ASGI `lifespan` connection. 16 | 17 | * **CONNECTING** - Initial state. The ASGI application instance will be run with 18 | the connection scope containing the `lifespan` type. 19 | * **STARTUP** - The lifespan startup event has been pushed to the queue to be 20 | received by the application. 21 | * **SHUTDOWN** - The lifespan shutdown event has been pushed to the queue to be 22 | received by the application. 23 | * **FAILED** - A lifespan failure has been detected, and the connection will be 24 | closed with an error. 25 | * **UNSUPPORTED** - An application attempted to send a message before receiving 26 | the lifespan startup event. If the lifespan argument is "on", then the connection 27 | will be closed with an error. 28 | """ 29 | 30 | CONNECTING = enum.auto() 31 | STARTUP = enum.auto() 32 | SHUTDOWN = enum.auto() 33 | FAILED = enum.auto() 34 | UNSUPPORTED = enum.auto() 35 | 36 | 37 | class LifespanCycle: 38 | """ 39 | Manages the application cycle for an ASGI `lifespan` connection. 40 | 41 | * **app** - An asynchronous callable that conforms to version 3.0 of the ASGI 42 | specification. This will usually be an ASGI framework application instance. 43 | * **lifespan** - A string to configure lifespan support. Choices are `auto`, `on`, 44 | and `off`. Default is `auto`. 45 | * **state** - An enumerated `LifespanCycleState` type that indicates the state of 46 | the ASGI connection. 47 | * **exception** - An exception raised while handling the ASGI event. This may or 48 | may not be raised depending on the state. 49 | * **app_queue** - An asyncio queue (FIFO) containing messages to be received by the 50 | application. 51 | * **startup_event** - An asyncio event object used to control the application 52 | startup flow. 53 | * **shutdown_event** - An asyncio event object used to control the application 54 | shutdown flow. 55 | """ 56 | 57 | def __init__(self, app: ASGI, lifespan: LifespanMode) -> None: 58 | self.app = app 59 | self.lifespan = lifespan 60 | self.state: LifespanCycleState = LifespanCycleState.CONNECTING 61 | self.exception: BaseException | None = None 62 | self.loop = asyncio.get_event_loop() 63 | self.app_queue: asyncio.Queue[Message] = asyncio.Queue() 64 | self.startup_event: asyncio.Event = asyncio.Event() 65 | self.shutdown_event: asyncio.Event = asyncio.Event() 66 | self.logger = logging.getLogger("mangum.lifespan") 67 | self.lifespan_state: dict[str, Any] = {} 68 | 69 | def __enter__(self) -> None: 70 | """Runs the event loop for application startup.""" 71 | self.loop.create_task(self.run()) 72 | self.loop.run_until_complete(self.startup()) 73 | 74 | def __exit__( 75 | self, 76 | exc_type: type[BaseException] | None, 77 | exc_value: BaseException | None, 78 | traceback: TracebackType | None, 79 | ) -> None: 80 | """Runs the event loop for application shutdown.""" 81 | self.loop.run_until_complete(self.shutdown()) 82 | 83 | async def run(self) -> None: 84 | """Calls the application with the `lifespan` connection scope.""" 85 | try: 86 | await self.app( 87 | {"type": "lifespan", "asgi": {"spec_version": "2.0", "version": "3.0"}, "state": self.lifespan_state}, 88 | self.receive, 89 | self.send, 90 | ) 91 | except LifespanUnsupported: 92 | self.logger.info("ASGI 'lifespan' protocol appears unsupported.") 93 | except (LifespanFailure, UnexpectedMessage) as exc: 94 | self.exception = exc 95 | except BaseException as exc: 96 | self.logger.error("Exception in 'lifespan' protocol.", exc_info=exc) 97 | finally: 98 | self.startup_event.set() 99 | self.shutdown_event.set() 100 | 101 | async def receive(self) -> Message: 102 | """Awaited by the application to receive ASGI `lifespan` events.""" 103 | if self.state is LifespanCycleState.CONNECTING: 104 | # Connection established. The next event returned by the queue will be 105 | # `lifespan.startup` to inform the application that the connection is 106 | # ready to receive lifespan messages. 107 | self.state = LifespanCycleState.STARTUP 108 | 109 | elif self.state is LifespanCycleState.STARTUP: 110 | # Connection shutting down. The next event returned by the queue will be 111 | # `lifespan.shutdown` to inform the application that the connection is now 112 | # closing so that it may perform cleanup. 113 | self.state = LifespanCycleState.SHUTDOWN 114 | 115 | return await self.app_queue.get() 116 | 117 | async def send(self, message: Message) -> None: 118 | """Awaited by the application to send ASGI `lifespan` events.""" 119 | message_type = message["type"] 120 | self.logger.info("%s: '%s' event received from application.", self.state, message_type) 121 | 122 | if self.state is LifespanCycleState.CONNECTING: 123 | if self.lifespan == "on": 124 | raise LifespanFailure("Lifespan connection failed during startup and lifespan is 'on'.") 125 | 126 | # If a message is sent before the startup event is received by the 127 | # application, then assume that lifespan is unsupported. 128 | self.state = LifespanCycleState.UNSUPPORTED 129 | raise LifespanUnsupported("Lifespan protocol appears unsupported.") 130 | 131 | if message_type not in ( 132 | "lifespan.startup.complete", 133 | "lifespan.shutdown.complete", 134 | "lifespan.startup.failed", 135 | "lifespan.shutdown.failed", 136 | ): 137 | self.state = LifespanCycleState.FAILED 138 | raise UnexpectedMessage(f"Unexpected '{message_type}' event received.") 139 | 140 | if self.state is LifespanCycleState.STARTUP: 141 | if message_type == "lifespan.startup.complete": 142 | self.startup_event.set() 143 | elif message_type == "lifespan.startup.failed": 144 | self.state = LifespanCycleState.FAILED 145 | self.startup_event.set() 146 | message_value = message.get("message", "") 147 | raise LifespanFailure(f"Lifespan startup failure. {message_value}") 148 | 149 | elif self.state is LifespanCycleState.SHUTDOWN: 150 | if message_type == "lifespan.shutdown.complete": 151 | self.shutdown_event.set() 152 | elif message_type == "lifespan.shutdown.failed": 153 | self.state = LifespanCycleState.FAILED 154 | self.shutdown_event.set() 155 | message_value = message.get("message", "") 156 | raise LifespanFailure(f"Lifespan shutdown failure. {message_value}") 157 | 158 | async def startup(self) -> None: 159 | """Pushes the `lifespan` startup event to the queue and handles errors.""" 160 | self.logger.info("Waiting for application startup.") 161 | await self.app_queue.put({"type": "lifespan.startup"}) 162 | await self.startup_event.wait() 163 | if self.state is LifespanCycleState.FAILED: 164 | raise LifespanFailure(self.exception) 165 | 166 | if not self.exception: 167 | self.logger.info("Application startup complete.") 168 | else: 169 | self.logger.info("Application startup failed.") 170 | 171 | async def shutdown(self) -> None: 172 | """Pushes the `lifespan` shutdown event to the queue and handles errors.""" 173 | self.logger.info("Waiting for application shutdown.") 174 | await self.app_queue.put({"type": "lifespan.shutdown"}) 175 | await self.shutdown_event.wait() 176 | if self.state is LifespanCycleState.FAILED: 177 | raise LifespanFailure(self.exception) 178 | -------------------------------------------------------------------------------- /mangum/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kludex/mangum/fbd783574382bff7dfddcadd1054590af7bb6a00/mangum/py.typed -------------------------------------------------------------------------------- /mangum/types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import ( 4 | Any, 5 | Awaitable, 6 | Callable, 7 | Dict, 8 | List, 9 | MutableMapping, 10 | Sequence, 11 | Union, 12 | ) 13 | 14 | from typing_extensions import Literal, Protocol, TypeAlias, TypedDict 15 | 16 | LambdaEvent = Dict[str, Any] 17 | QueryParams: TypeAlias = MutableMapping[str, Union[str, Sequence[str]]] 18 | 19 | 20 | class LambdaCognitoIdentity(Protocol): 21 | """Information about the Amazon Cognito identity that authorized the request. 22 | 23 | **cognito_identity_id** - The authenticated Amazon Cognito identity. 24 | **cognito_identity_pool_id** - The Amazon Cognito identity pool that authorized the 25 | invocation. 26 | """ 27 | 28 | cognito_identity_id: str 29 | cognito_identity_pool_id: str 30 | 31 | 32 | class LambdaMobileClient(Protocol): 33 | """Mobile client information for the application and the device. 34 | 35 | **installation_id** - A unique identifier for an installation instance of an 36 | application. 37 | **app_title** - The title of the application. For example, "My App". 38 | **app_version_code** - The version of the application. For example, "V2.0". 39 | **app_version_name** - The version code for the application. For example, 3. 40 | **app_package_name** - The name of the package. For example, "com.example.my_app". 41 | """ 42 | 43 | installation_id: str 44 | app_title: str 45 | app_version_name: str 46 | app_version_code: str 47 | app_package_name: str 48 | 49 | 50 | class LambdaMobileClientContext(Protocol): 51 | """Information about client application and device when invoked via AWS Mobile SDK. 52 | 53 | **client** - A dict of name-value pairs that describe the mobile client application. 54 | **custom** - A dict of custom values set by the mobile client application. 55 | **env** - A dict of environment information provided by the AWS SDK. 56 | """ 57 | 58 | client: LambdaMobileClient 59 | custom: dict[str, Any] 60 | env: dict[str, Any] 61 | 62 | 63 | class LambdaContext(Protocol): 64 | """The context object passed to the handler function. 65 | 66 | **function_name** - The name of the Lambda function. 67 | **function_version** - The version of the function. 68 | **invoked_function_arn** - The Amazon Resource Name (ARN) that's used to invoke the 69 | function. Indicates if the invoker specified a version number or alias. 70 | **memory_limit_in_mb** - The amount of memory that's allocated for the function. 71 | **aws_request_id** - The identifier of the invocation request. 72 | **log_group_name** - The log group for the function. 73 | **log_stream_name** - The log stream for the function instance. 74 | **identity** - (mobile apps) Information about the Amazon Cognito identity that 75 | authorized the request. 76 | **client_context** - (mobile apps) Client context that's provided to Lambda by the 77 | client application. 78 | """ 79 | 80 | function_name: str 81 | function_version: str 82 | invoked_function_arn: str 83 | memory_limit_in_mb: int 84 | aws_request_id: str 85 | log_group_name: str 86 | log_stream_name: str 87 | identity: LambdaCognitoIdentity | None 88 | client_context: LambdaMobileClientContext | None 89 | 90 | def get_remaining_time_in_millis(self) -> int: 91 | """Returns the number of milliseconds left before the execution times out.""" 92 | ... # pragma: no cover 93 | 94 | 95 | Headers: TypeAlias = List[List[bytes]] 96 | Message: TypeAlias = MutableMapping[str, Any] 97 | Scope: TypeAlias = MutableMapping[str, Any] 98 | Receive: TypeAlias = Callable[[], Awaitable[Message]] 99 | Send: TypeAlias = Callable[[Message], Awaitable[None]] 100 | 101 | 102 | class ASGI(Protocol): 103 | async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: ... # pragma: no cover 104 | 105 | 106 | LifespanMode: TypeAlias = Literal["auto", "on", "off"] 107 | 108 | 109 | class Response(TypedDict): 110 | status: int 111 | headers: Headers 112 | body: bytes 113 | 114 | 115 | class LambdaConfig(TypedDict): 116 | api_gateway_base_path: str 117 | text_mime_types: list[str] 118 | exclude_headers: list[str] 119 | 120 | 121 | class LambdaHandler(Protocol): 122 | def __init__(self, *args: Any) -> None: ... # pragma: no cover 123 | 124 | @classmethod 125 | def infer(cls, event: LambdaEvent, context: LambdaContext, config: LambdaConfig) -> bool: ... # pragma: no cover 126 | 127 | @property 128 | def body(self) -> bytes: ... # pragma: no cover 129 | 130 | @property 131 | def scope(self) -> Scope: ... # pragma: no cover 132 | 133 | def __call__(self, response: Response) -> dict[str, Any]: ... # pragma: no cover 134 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Mangum 2 | site_description: AWS Lambda & API Gateway support for ASGI 3 | site_url: http://mangum.fastapiexpert.com 4 | 5 | theme: 6 | name: material 7 | palette: 8 | primary: brown 9 | accent: orange 10 | 11 | repo_name: Kludex/mangum 12 | repo_url: https://github.com/Kludex/mangum 13 | edit_uri: edit/main/docs/ 14 | 15 | nav: 16 | - Introduction: index.md 17 | - Adapter: adapter.md 18 | - HTTP: http.md 19 | - Lifespan: lifespan.md 20 | - ASGI Frameworks: asgi-frameworks.md 21 | - External Links: external-links.md 22 | - Contributing: contributing.md 23 | 24 | markdown_extensions: 25 | - mkautodoc 26 | - markdown.extensions.codehilite: 27 | guess_lang: false 28 | - pymdownx.details 29 | - pymdownx.superfences 30 | - pymdownx.snippets 31 | - pymdownx.highlight: 32 | pygments_lang_class: true 33 | 34 | plugins: 35 | - search 36 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "mangum" 7 | version = "0.19.0" 8 | authors = [ 9 | { name = "Jordan Eremieff", email = "jordan@eremieff.com" }, 10 | { name = "Marcelo Trylesinski", email = "marcelotryle@gmail.com" }, 11 | ] 12 | description = "AWS Lambda support for ASGI applications" 13 | readme = "README.md" 14 | requires-python = ">=3.7" 15 | classifiers = [ 16 | "License :: OSI Approved :: MIT License", 17 | "Operating System :: OS Independent", 18 | "Programming Language :: Python", 19 | "Programming Language :: Python :: 3.7", 20 | "Programming Language :: Python :: 3.8", 21 | "Programming Language :: Python :: 3.9", 22 | "Programming Language :: Python :: 3.10", 23 | "Programming Language :: Python :: 3.11", 24 | "Programming Language :: Python :: 3.12", 25 | "Programming Language :: Python :: 3.13", 26 | "Topic :: Internet :: WWW/HTTP", 27 | ] 28 | dependencies = ["typing_extensions"] 29 | 30 | [tool.uv] 31 | dev-dependencies = [ 32 | "pytest", 33 | "pytest-cov", 34 | "ruff", 35 | "starlette", 36 | "quart", 37 | "hypercorn<0.15.0; python_version < '3.8'", 38 | "hypercorn>=0.15.0; python_version >= '3.8'", 39 | "mypy", 40 | "brotli", 41 | "brotli-asgi", 42 | "mkautodoc", 43 | "mkdocs>=1.6.0; python_version >= '3.12'", 44 | "mkdocs-material; python_version >= '3.12'", 45 | ] 46 | 47 | [project.urls] 48 | Homepage = "https://github.com/Kludex/mangum" 49 | Documentation = "https://mangum.fastapiexpert.com" 50 | Changelog = "https://github.com/Kludex/mangum/blob/main/CHANGELOG.md" 51 | Funding = "https://github.com/sponsors/Kludex" 52 | Source = "https://github.com/Kludex/mangum" 53 | 54 | [tool.ruff] 55 | line-length = 120 56 | 57 | [tool.ruff.lint] 58 | select = [ 59 | "E", # https://docs.astral.sh/ruff/rules/#error-e 60 | "F", # https://docs.astral.sh/ruff/rules/#pyflakes-f 61 | "I", # https://docs.astral.sh/ruff/rules/#isort-i 62 | "FA", # https://docs.astral.sh/ruff/rules/#flake8-future-annotations-fa 63 | "UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up 64 | "RUF100", # https://docs.astral.sh/ruff/rules/#ruff-specific-rules-ruf 65 | ] 66 | ignore = ["UP031"] # https://docs.astral.sh/ruff/rules/printf-string-formatting/ 67 | 68 | [tool.mypy] 69 | strict = true 70 | 71 | [tool.pytest.ini_options] 72 | log_cli = true 73 | log_cli_level = "INFO" 74 | log_cli_format = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)" 75 | log_cli_date_format = "%Y-%m-%d %H:%M:%S" 76 | addopts = "-rXs --strict-config --strict-markers" 77 | xfail_strict = true 78 | filterwarnings = [ 79 | # Turn warnings that aren't filtered into exceptions 80 | "error", 81 | "ignore::DeprecationWarning:starlette", 82 | "ignore: There is no current event loop:DeprecationWarning", 83 | "ignore: 'pkgutil.get_loader' is deprecated.*:DeprecationWarning", 84 | "ignore: ast.Str is deprecated and will be removed in Python 3.14; use ast.Constant instead:DeprecationWarning", 85 | "ignore: Attribute s is deprecated and will be removed in Python 3.14; use value instead:DeprecationWarning", 86 | "ignore: Constant.__init__ got an unexpected keyword argument 's'. Support for arbitrary keyword arguments is deprecated and will be removed in Python 3.15.:DeprecationWarning", 87 | "ignore: Constant.__init__ missing 1 required positional argument.*:DeprecationWarning", 88 | ] 89 | 90 | [tool.coverage.run] 91 | source_pkgs = ["mangum", "tests"] 92 | 93 | [tool.coverage.report] 94 | exclude_lines = [ 95 | "pragma: no cover", 96 | "pragma: nocover", 97 | "if typing.TYPE_CHECKING:", 98 | "@typing.overload", 99 | "raise NotImplementedError", 100 | ] 101 | -------------------------------------------------------------------------------- /scripts/README.md: -------------------------------------------------------------------------------- 1 | # Development Scripts 2 | 3 | * `scripts/setup` - Install dependencies. 4 | * `scripts/test` - Run the test suite. 5 | * `scripts/lint` - Run the code format. 6 | * `scripts/check` - Run the lint in check mode, and the type checker. 7 | 8 | Styled after GitHub's ["Scripts to Rule Them All"](https://github.com/github/scripts-to-rule-them-all). 9 | -------------------------------------------------------------------------------- /scripts/check: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | set -x 4 | 5 | SOURCE_FILES="mangum tests" 6 | 7 | uvx ruff format --check --diff $SOURCE_FILES 8 | uvx ruff check $SOURCE_FILES 9 | uvx mypy mangum 10 | -------------------------------------------------------------------------------- /scripts/docs: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | set -x # print executed commands to the terminal 4 | 5 | uv run mkdocs serve 6 | -------------------------------------------------------------------------------- /scripts/lint: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | set -x 4 | 5 | SOURCE_FILES="mangum tests" 6 | 7 | uvx ruff format $SOURCE_FILES 8 | uvx ruff check --fix $SOURCE_FILES 9 | -------------------------------------------------------------------------------- /scripts/setup: -------------------------------------------------------------------------------- 1 | #!/bin/sh -ex 2 | 3 | uv sync --frozen 4 | -------------------------------------------------------------------------------- /scripts/test: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | set -x # print executed commands to the terminal 4 | 5 | uv run pytest --ignore venv --cov=mangum --cov=tests --cov-fail-under=100 --cov-report=term-missing "${@}" 6 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kludex/mangum/fbd783574382bff7dfddcadd1054590af7bb6a00/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | 6 | @pytest.fixture 7 | def mock_aws_api_gateway_event(request): 8 | method = request.param[0] 9 | body = request.param[1] 10 | multi_value_query_parameters = request.param[2] 11 | event = { 12 | "path": "/test/hello", 13 | "body": body, 14 | "headers": { 15 | "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", 16 | "Accept-Encoding": "gzip, deflate, lzma, sdch, br", 17 | "Accept-Language": "en-US,en;q=0.8", 18 | "CloudFront-Forwarded-Proto": "https", 19 | "CloudFront-Is-Desktop-Viewer": "true", 20 | "CloudFront-Is-Mobile-Viewer": "false", 21 | "CloudFront-Is-SmartTV-Viewer": "false", 22 | "CloudFront-Is-Tablet-Viewer": "false", 23 | "CloudFront-Viewer-Country": "US", 24 | "Cookie": "cookie1; cookie2", 25 | "Host": "test.execute-api.us-west-2.amazonaws.com", 26 | "Upgrade-Insecure-Requests": "1", 27 | "X-Forwarded-For": "192.168.100.1, 192.168.1.1", 28 | "X-Forwarded-Port": "443", 29 | "X-Forwarded-Proto": "https", 30 | }, 31 | "pathParameters": {"proxy": "hello"}, 32 | "requestContext": { 33 | "accountId": "123456789012", 34 | "resourceId": "us4z18", 35 | "stage": "Prod", 36 | "requestId": "41b45ea3-70b5-11e6-b7bd-69b5aaebc7d9", 37 | "identity": { 38 | "cognitoIdentityPoolId": "", 39 | "accountId": "", 40 | "cognitoIdentityId": "", 41 | "caller": "", 42 | "apiKey": "", 43 | "sourceIp": "192.168.100.1", 44 | "cognitoAuthenticationType": "", 45 | "cognitoAuthenticationProvider": "", 46 | "userArn": "", 47 | "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36 OPR/39.0.2256.48", # noqa: E501 48 | "user": "", 49 | }, 50 | "resourcePath": "/{proxy+}", 51 | "httpMethod": method, 52 | "apiId": "123", 53 | }, 54 | "resource": "/{proxy+}", 55 | "httpMethod": method, 56 | "queryStringParameters": ( 57 | {k: v[0] for k, v in multi_value_query_parameters.items()} if multi_value_query_parameters else None 58 | ), 59 | "multiValueQueryStringParameters": multi_value_query_parameters or None, 60 | "stageVariables": {"stageVarName": "stageVarValue"}, 61 | } 62 | return event 63 | 64 | 65 | @pytest.fixture 66 | def mock_http_api_event_v2(request): 67 | method = request.param[0] 68 | body = request.param[1] 69 | multi_value_query_parameters = request.param[2] 70 | query_string = request.param[3] 71 | event = { 72 | "version": "2.0", 73 | "routeKey": "$default", 74 | "rawPath": "/my/path", 75 | "rawQueryString": query_string, 76 | "cookies": ["cookie1", "cookie2"], 77 | "headers": { 78 | "accept-encoding": "gzip,deflate", 79 | "x-forwarded-port": "443", 80 | "x-forwarded-proto": "https", 81 | "host": "test.execute-api.us-west-2.amazonaws.com", 82 | }, 83 | "queryStringParameters": ( 84 | {k: v[0] for k, v in multi_value_query_parameters.items()} if multi_value_query_parameters else None 85 | ), 86 | "requestContext": { 87 | "accountId": "123456789012", 88 | "apiId": "api-id", 89 | "authorizer": { 90 | "jwt": { 91 | "claims": {"claim1": "value1", "claim2": "value2"}, 92 | "scopes": ["scope1", "scope2"], 93 | } 94 | }, 95 | "domainName": "id.execute-api.us-east-1.amazonaws.com", 96 | "domainPrefix": "id", 97 | "http": { 98 | "method": method, 99 | "path": "/my/path", 100 | "protocol": "HTTP/1.1", 101 | "sourceIp": "192.168.100.1", 102 | "userAgent": "agent", 103 | }, 104 | "requestId": "id", 105 | "routeKey": "$default", 106 | "stage": "$default", 107 | "time": "12/Mar/2020:19:03:58 +0000", 108 | "timeEpoch": 1583348638390, 109 | }, 110 | "body": body, 111 | "pathParameters": {"parameter1": "value1"}, 112 | "isBase64Encoded": False, 113 | "stageVariables": {"stageVariable1": "value1", "stageVariable2": "value2"}, 114 | } 115 | 116 | return event 117 | 118 | 119 | @pytest.fixture 120 | def mock_http_api_event_v1(request): 121 | method = request.param[0] 122 | body = request.param[1] 123 | multi_value_query_parameters = request.param[2] 124 | query_string = request.param[3] 125 | event = { 126 | "version": "1.0", 127 | "routeKey": "$default", 128 | "rawPath": "/my/path", 129 | "path": "/my/path", 130 | "httpMethod": method, 131 | "rawQueryString": query_string, 132 | "cookies": ["cookie1", "cookie2"], 133 | "headers": { 134 | "accept-encoding": "gzip,deflate", 135 | "x-forwarded-port": "443", 136 | "x-forwarded-proto": "https", 137 | "host": "test.execute-api.us-west-2.amazonaws.com", 138 | }, 139 | "queryStringParameters": ( 140 | {k: v[-1] for k, v in multi_value_query_parameters.items()} if multi_value_query_parameters else None 141 | ), 142 | "multiValueQueryStringParameters": ( 143 | {k: v for k, v in multi_value_query_parameters.items()} if multi_value_query_parameters else None 144 | ), 145 | "requestContext": { 146 | "accountId": "123456789012", 147 | "apiId": "api-id", 148 | "authorizer": { 149 | "jwt": { 150 | "claims": {"claim1": "value1", "claim2": "value2"}, 151 | "scopes": ["scope1", "scope2"], 152 | } 153 | }, 154 | "domainName": "id.execute-api.us-east-1.amazonaws.com", 155 | "domainPrefix": "id", 156 | "http": { 157 | "protocol": "HTTP/1.1", 158 | "sourceIp": "192.168.100.1", 159 | "userAgent": "agent", 160 | }, 161 | "requestId": "id", 162 | "routeKey": "$default", 163 | "stage": "$default", 164 | "time": "12/Mar/2020:19:03:58 +0000", 165 | "timeEpoch": 1583348638390, 166 | }, 167 | "body": body, 168 | "pathParameters": {"parameter1": "value1"}, 169 | "isBase64Encoded": False, 170 | "stageVariables": {"stageVariable1": "value1", "stageVariable2": "value2"}, 171 | } 172 | 173 | return event 174 | 175 | 176 | @pytest.fixture 177 | def mock_lambda_at_edge_event(request): 178 | method = request.param[0] 179 | path = request.param[1] 180 | query_string = request.param[2] 181 | body = request.param[3] 182 | 183 | headers_raw = { 184 | "accept-encoding": "gzip,deflate", 185 | "x-forwarded-port": "443", 186 | "x-forwarded-for": "192.168.100.1", 187 | "x-forwarded-proto": "https", 188 | "host": "test.execute-api.us-west-2.amazonaws.com", 189 | } 190 | headers = {} 191 | for key, value in headers_raw.items(): 192 | headers[key.lower()] = [{"key": key, "value": value}] 193 | 194 | event = { 195 | "Records": [ 196 | { 197 | "cf": { 198 | "config": { 199 | "distributionDomainName": "mock-distribution.local.localhost", 200 | "distributionId": "ABC123DEF456G", 201 | "eventType": "origin-request", 202 | "requestId": "lBEBo2N0JKYUP2JXwn_4am2xAXB2GzcL2FlwXI8G59PA8wghF2ImFQ==", 203 | }, 204 | "request": { 205 | "clientIp": "192.168.100.1", 206 | "headers": headers, 207 | "method": method, 208 | "origin": { 209 | "custom": { 210 | "customHeaders": { 211 | "x-lae-env-custom-var": [ 212 | { 213 | "key": "x-lae-env-custom-var", 214 | "value": "environment variable", 215 | } 216 | ], 217 | }, 218 | "domainName": "www.example.com", 219 | "keepaliveTimeout": 5, 220 | "path": "", 221 | "port": 80, 222 | "protocol": "http", 223 | "readTimeout": 30, 224 | "sslProtocols": ["TLSv1", "TLSv1.1", "TLSv1.2"], 225 | } 226 | }, 227 | "querystring": query_string, 228 | "uri": path, 229 | }, 230 | } 231 | } 232 | ] 233 | } 234 | 235 | if body is not None: 236 | event["Records"][0]["cf"]["request"]["body"] = { 237 | "inputTruncated": False, 238 | "action": "read-only", 239 | "encoding": "text", 240 | "data": body, 241 | } 242 | 243 | return dict(method=method, path=path, query_string=query_string, body=body, event=event) 244 | 245 | 246 | @pytest.fixture(scope="session", autouse=True) 247 | def aws_credentials(): 248 | """Mocked AWS Credentials for moto.""" 249 | os.environ["AWS_DEFAULT_REGION"] = "testing" 250 | os.environ["AWS_ACCESS_KEY_ID"] = "testing" 251 | os.environ["AWS_SECRET_ACCESS_KEY"] = "testing" 252 | os.environ["AWS_SECURITY_TOKEN"] = "testing" 253 | os.environ["AWS_SESSION_TOKEN"] = "testing" 254 | -------------------------------------------------------------------------------- /tests/handlers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kludex/mangum/fbd783574382bff7dfddcadd1054590af7bb6a00/tests/handlers/__init__.py -------------------------------------------------------------------------------- /tests/handlers/test_alb.py: -------------------------------------------------------------------------------- 1 | """ 2 | References: 3 | 1. https://docs.aws.amazon.com/lambda/latest/dg/services-alb.html 4 | 2. https://docs.aws.amazon.com/elasticloadbalancing/latest/application/lambda-functions.html # noqa: E501 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | import pytest 10 | 11 | from mangum import Mangum 12 | from mangum.handlers.alb import ALB 13 | 14 | 15 | def get_mock_aws_alb_event( 16 | method, 17 | path, 18 | query_parameters: dict[str, list[str]] | None, 19 | headers: dict[str, list[str]] | None, 20 | body, 21 | body_base64_encoded, 22 | multi_value_headers: bool, 23 | ): 24 | """Return a mock AWS ELB event. 25 | 26 | The `query_parameters` parameter must be given in the 27 | `multiValueQueryStringParameters` format - and if `multi_value_headers` 28 | is disabled, then they are simply transformed in to the 29 | `queryStringParameters` format. 30 | Similarly for `headers`. 31 | If `headers` is None, then some defaults will be used. 32 | if `query_parameters` is None, then no query parameters will be used. 33 | """ 34 | resp = { 35 | "requestContext": { 36 | "elb": { 37 | "targetGroupArn": ( 38 | "arn:aws:elasticloadbalancing:us-east-2:123456789012:" 39 | "targetgroup/lambda-279XGJDqGZ5rsrHC2Fjr/49e9d65c45c6791a" 40 | ) 41 | } 42 | }, 43 | "httpMethod": method, 44 | "path": path, 45 | "body": body, 46 | "isBase64Encoded": body_base64_encoded, 47 | } 48 | 49 | if headers is None: 50 | headers = { 51 | "accept": ["text/html,application/xhtml+xml,application/xml;" "q=0.9,image/webp,image/apng,*/*;q=0.8"], 52 | "accept-encoding": ["gzip"], 53 | "accept-language": ["en-US,en;q=0.9"], 54 | "connection": ["keep-alive"], 55 | "host": ["lambda-alb-123578498.us-east-2.elb.amazonaws.com"], 56 | "upgrade-insecure-requests": ["1"], 57 | "user-agent": [ 58 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " 59 | "(KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36" 60 | ], 61 | "x-amzn-trace-id": ["Root=1-5c536348-3d683b8b04734faae651f476"], 62 | "x-forwarded-for": ["72.12.164.125"], 63 | "x-forwarded-port": ["80"], 64 | "x-forwarded-proto": ["http"], 65 | "x-imforwards": ["20"], 66 | } 67 | 68 | query_parameters = {} if query_parameters is None else query_parameters 69 | 70 | # Only set one of `queryStringParameters`/`multiValueQueryStringParameters` 71 | # and one of `headers`/multiValueHeaders (per AWS docs for ALB/lambda) 72 | if multi_value_headers: 73 | resp["multiValueQueryStringParameters"] = query_parameters 74 | resp["multiValueHeaders"] = headers 75 | else: 76 | # Take the last query parameter/cookie (per AWS docs for ALB/lambda) 77 | resp["queryStringParameters"] = {k: (v[-1] if len(v) > 0 else []) for k, v in query_parameters.items()} 78 | resp["headers"] = {k: (v[-1] if len(v) > 0 else []) for k, v in headers.items()} 79 | 80 | return resp 81 | 82 | 83 | @pytest.mark.parametrize( 84 | "method,path,query_parameters,headers,req_body,body_base64_encoded," "query_string,scope_body,multi_value_headers", 85 | [ 86 | ("GET", "/hello/world", None, None, None, False, b"", None, False), 87 | ( 88 | "GET", 89 | "/lambda", 90 | { 91 | "q1": ["1234ABCD"], 92 | "q2": ["b+c"], # not encoded 93 | "q3": ["b%20c"], # encoded 94 | "q4": ["/some/path/"], # not encoded 95 | "q5": ["%2Fsome%2Fpath%2F"], # encoded 96 | }, 97 | None, 98 | "", 99 | False, 100 | b"q1=1234ABCD&q2=b+c&q3=b+c&q4=%2Fsome%2Fpath%2F&q5=%2Fsome%2Fpath%2F", 101 | "", 102 | False, 103 | ), 104 | ( 105 | "POST", 106 | "/", 107 | {"name": ["me"]}, 108 | None, 109 | "field1=value1&field2=value2", 110 | False, 111 | b"name=me", 112 | b"field1=value1&field2=value2", 113 | False, 114 | ), 115 | # Duplicate query params with multi-value headers disabled: 116 | ( 117 | "POST", 118 | "/", 119 | {"name": ["me", "you"]}, 120 | None, 121 | None, 122 | False, 123 | b"name=you", 124 | None, 125 | False, 126 | ), 127 | # Duplicate query params with multi-value headers enable: 128 | ( 129 | "GET", 130 | "/my/resource", 131 | {"name": ["me", "you"]}, 132 | None, 133 | None, 134 | False, 135 | b"name=me&name=you", 136 | None, 137 | True, 138 | ), 139 | ( 140 | "GET", 141 | "", 142 | {"name": ["me", "you"], "pet": ["dog"]}, 143 | None, 144 | None, 145 | False, 146 | b"name=me&name=you&pet=dog", 147 | None, 148 | True, 149 | ), 150 | # A 1x1 red px gif 151 | ( 152 | "POST", 153 | "/img", 154 | None, 155 | None, 156 | b"R0lGODdhAQABAIABAP8AAAAAACwAAAAAAQABAAACAkQBADs=", 157 | True, 158 | b"", 159 | b"GIF87a\x01\x00\x01\x00\x80\x01\x00\xff\x00\x00\x00\x00\x00," 160 | b"\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;", 161 | False, 162 | ), 163 | ( 164 | "POST", 165 | "/form-submit", 166 | None, 167 | None, 168 | b"say=Hi&to=Mom", 169 | False, 170 | b"", 171 | b"say=Hi&to=Mom", 172 | False, 173 | ), 174 | ], 175 | ) 176 | def test_aws_alb_scope_real( 177 | method, 178 | path, 179 | query_parameters, 180 | headers, 181 | req_body, 182 | body_base64_encoded, 183 | query_string, 184 | scope_body, 185 | multi_value_headers, 186 | ): 187 | event = get_mock_aws_alb_event( 188 | method, 189 | path, 190 | query_parameters, 191 | headers, 192 | req_body, 193 | body_base64_encoded, 194 | multi_value_headers, 195 | ) 196 | example_context = {} 197 | handler = ALB(event, example_context, {"api_gateway_base_path": "/"}) 198 | 199 | scope_path = path 200 | if scope_path == "": 201 | scope_path = "/" 202 | 203 | assert isinstance(handler.body, bytes) 204 | assert handler.scope == { 205 | "asgi": {"version": "3.0", "spec_version": "2.0"}, 206 | "aws.context": {}, 207 | "aws.event": event, 208 | "client": ("72.12.164.125", 0), 209 | "headers": [ 210 | [ 211 | b"accept", 212 | b"text/html,application/xhtml+xml,application/xml;q=0.9,image/" b"webp,image/apng,*/*;q=0.8", 213 | ], 214 | [b"accept-encoding", b"gzip"], 215 | [b"accept-language", b"en-US,en;q=0.9"], 216 | [b"connection", b"keep-alive"], 217 | [b"host", b"lambda-alb-123578498.us-east-2.elb.amazonaws.com"], 218 | [b"upgrade-insecure-requests", b"1"], 219 | [ 220 | b"user-agent", 221 | b"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" 222 | b" (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36", 223 | ], 224 | [b"x-amzn-trace-id", b"Root=1-5c536348-3d683b8b04734faae651f476"], 225 | [b"x-forwarded-for", b"72.12.164.125"], 226 | [b"x-forwarded-port", b"80"], 227 | [b"x-forwarded-proto", b"http"], 228 | [b"x-imforwards", b"20"], 229 | ], 230 | "http_version": "1.1", 231 | "method": method, 232 | "path": scope_path, 233 | "query_string": query_string, 234 | "raw_path": None, 235 | "root_path": "", 236 | "scheme": "http", 237 | "server": ("lambda-alb-123578498.us-east-2.elb.amazonaws.com", 80), 238 | "type": "http", 239 | } 240 | 241 | if handler.body: 242 | assert handler.body == scope_body 243 | else: 244 | assert handler.body == b"" 245 | 246 | 247 | @pytest.mark.parametrize("multi_value_headers_enabled", (True, False)) 248 | def test_aws_alb_set_cookies(multi_value_headers_enabled) -> None: 249 | async def app(scope, receive, send): 250 | await send( 251 | { 252 | "type": "http.response.start", 253 | "status": 200, 254 | "headers": [ 255 | [b"content-type", b"text/plain; charset=utf-8"], 256 | [b"set-cookie", b"cookie1=cookie1; Secure"], 257 | [b"set-cookie", b"cookie2=cookie2; Secure"], 258 | ], 259 | } 260 | ) 261 | await send({"type": "http.response.body", "body": b"Hello, world!"}) 262 | 263 | handler = Mangum(app, lifespan="off") 264 | event = get_mock_aws_alb_event("GET", "/test", {}, None, None, False, multi_value_headers_enabled) 265 | response = handler(event, {}) 266 | 267 | expected_response = { 268 | "statusCode": 200, 269 | "isBase64Encoded": False, 270 | "body": "Hello, world!", 271 | } 272 | if multi_value_headers_enabled: 273 | expected_response["multiValueHeaders"] = { 274 | "set-cookie": ["cookie1=cookie1; Secure", "cookie2=cookie2; Secure"], 275 | "content-type": ["text/plain; charset=utf-8"], 276 | } 277 | else: 278 | expected_response["headers"] = { 279 | "content-type": "text/plain; charset=utf-8", 280 | # Should see case mutated keys to avoid duplicate keys: 281 | "set-cookie": "cookie1=cookie1; Secure", 282 | "Set-cookie": "cookie2=cookie2; Secure", 283 | } 284 | assert response == expected_response 285 | 286 | 287 | @pytest.mark.parametrize( 288 | "method,content_type,raw_res_body,res_body,res_base64_encoded", 289 | [ 290 | ("GET", b"text/plain; charset=utf-8", b"Hello world", "Hello world", False), 291 | # A 1x1 red px gif 292 | ( 293 | "POST", 294 | b"image/gif", 295 | b"GIF87a\x01\x00\x01\x00\x80\x01\x00\xff\x00\x00\x00\x00\x00," 296 | b"\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;", 297 | "R0lGODdhAQABAIABAP8AAAAAACwAAAAAAQABAAACAkQBADs=", 298 | True, 299 | ), 300 | ], 301 | ) 302 | def test_aws_alb_response(method, content_type, raw_res_body, res_body, res_base64_encoded): 303 | async def app(scope, receive, send): 304 | await send( 305 | { 306 | "type": "http.response.start", 307 | "status": 200, 308 | "headers": [[b"content-type", content_type]], 309 | } 310 | ) 311 | await send({"type": "http.response.body", "body": raw_res_body}) 312 | 313 | event = get_mock_aws_alb_event(method, "/test", {}, None, None, False, False) 314 | 315 | handler = Mangum(app, lifespan="off") 316 | 317 | response = handler(event, {}) 318 | assert response == { 319 | "statusCode": 200, 320 | "isBase64Encoded": res_base64_encoded, 321 | "headers": {"content-type": content_type.decode()}, 322 | "body": res_body, 323 | } 324 | 325 | 326 | def test_aws_alb_response_extra_mime_types(): 327 | content_type = b"application/x-yaml" 328 | utf_res_body = "name: 'John Doe'" 329 | raw_res_body = utf_res_body.encode() 330 | b64_res_body = "bmFtZTogJ0pvaG4gRG9lJw==" 331 | 332 | async def app(scope, receive, send): 333 | await send( 334 | { 335 | "type": "http.response.start", 336 | "status": 200, 337 | "headers": [[b"content-type", content_type]], 338 | } 339 | ) 340 | await send({"type": "http.response.body", "body": raw_res_body}) 341 | 342 | event = get_mock_aws_alb_event("GET", "/test", {}, None, None, False, False) 343 | 344 | # Test default behavior 345 | handler = Mangum(app, lifespan="off") 346 | response = handler(event, {}) 347 | assert content_type.decode() not in handler.config["text_mime_types"] 348 | assert response == { 349 | "statusCode": 200, 350 | "isBase64Encoded": True, 351 | "headers": {"content-type": content_type.decode()}, 352 | "body": b64_res_body, 353 | } 354 | 355 | # Test with modified text mime types 356 | handler = Mangum(app, lifespan="off") 357 | handler.config["text_mime_types"].append(content_type.decode()) 358 | response = handler(event, {}) 359 | assert response == { 360 | "statusCode": 200, 361 | "isBase64Encoded": False, 362 | "headers": {"content-type": content_type.decode()}, 363 | "body": utf_res_body, 364 | } 365 | 366 | 367 | @pytest.mark.parametrize("multi_value_headers_enabled", (True, False)) 368 | def test_aws_alb_exclude_headers(multi_value_headers_enabled) -> None: 369 | async def app(scope, receive, send): 370 | await send( 371 | { 372 | "type": "http.response.start", 373 | "status": 200, 374 | "headers": [ 375 | [b"content-type", b"text/plain; charset=utf-8"], 376 | [b"x-custom-header", b"test"], 377 | ], 378 | } 379 | ) 380 | await send({"type": "http.response.body", "body": b"Hello, world!"}) 381 | 382 | handler = Mangum(app, lifespan="off", exclude_headers=["x-custom-header"]) 383 | event = get_mock_aws_alb_event("GET", "/test", {}, None, None, False, multi_value_headers_enabled) 384 | response = handler(event, {}) 385 | 386 | expected_response = { 387 | "statusCode": 200, 388 | "isBase64Encoded": False, 389 | "body": "Hello, world!", 390 | } 391 | if multi_value_headers_enabled: 392 | expected_response["multiValueHeaders"] = { 393 | "content-type": ["text/plain; charset=utf-8"], 394 | } 395 | else: 396 | expected_response["headers"] = { 397 | "content-type": "text/plain; charset=utf-8", 398 | } 399 | assert response == expected_response 400 | -------------------------------------------------------------------------------- /tests/handlers/test_api_gateway.py: -------------------------------------------------------------------------------- 1 | import urllib.parse 2 | 3 | import pytest 4 | 5 | from mangum import Mangum 6 | from mangum.handlers.api_gateway import APIGateway 7 | 8 | 9 | def get_mock_aws_api_gateway_event(method, path, multi_value_query_parameters, body, body_base64_encoded): 10 | return { 11 | "path": path, 12 | "body": body, 13 | "isBase64Encoded": body_base64_encoded, 14 | "headers": { 15 | "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9," "image/webp,*/*;q=0.8", 16 | "Accept-Encoding": "gzip, deflate, lzma, sdch, br", 17 | "Accept-Language": "en-US,en;q=0.8", 18 | "CloudFront-Forwarded-Proto": "https", 19 | "CloudFront-Is-Desktop-Viewer": "true", 20 | "CloudFront-Is-Mobile-Viewer": "false", 21 | "CloudFront-Is-SmartTV-Viewer": "false", 22 | "CloudFront-Is-Tablet-Viewer": "false", 23 | "CloudFront-Viewer-Country": "US", 24 | "Cookie": "cookie1; cookie2", 25 | "Host": "test.execute-api.us-west-2.amazonaws.com", 26 | "Upgrade-Insecure-Requests": "1", 27 | "X-Forwarded-For": "192.168.100.1, 192.168.1.1", 28 | "X-Forwarded-Port": "443", 29 | "X-Forwarded-Proto": "https", 30 | }, 31 | "pathParameters": {"proxy": "hello"}, 32 | "requestContext": { 33 | "accountId": "123456789012", 34 | "resourceId": "us4z18", 35 | "stage": "Prod", 36 | "requestId": "41b45ea3-70b5-11e6-b7bd-69b5aaebc7d9", 37 | "identity": { 38 | "cognitoIdentityPoolId": "", 39 | "accountId": "", 40 | "cognitoIdentityId": "", 41 | "caller": "", 42 | "apiKey": "", 43 | "sourceIp": "192.168.100.1", 44 | "cognitoAuthenticationType": "", 45 | "cognitoAuthenticationProvider": "", 46 | "userArn": "", 47 | "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) " 48 | "AppleWebKit/537.36 (KHTML, like Gecko) " 49 | "Chrome/52.0.2743.82 Safari/537.36 OPR/39.0.2256.48", 50 | "user": "", 51 | }, 52 | "resourcePath": "/{proxy+}", 53 | "httpMethod": method, 54 | "apiId": "123", 55 | }, 56 | "resource": "/{proxy+}", 57 | "httpMethod": method, 58 | "multiValueQueryStringParameters": ( 59 | {k: v for k, v in multi_value_query_parameters.items()} if multi_value_query_parameters else None 60 | ), 61 | "stageVariables": {"stageVarName": "stageVarValue"}, 62 | } 63 | 64 | 65 | def test_aws_api_gateway_scope_basic(): 66 | """ 67 | Test the event from the AWS docs 68 | """ 69 | example_event = { 70 | "resource": "/", 71 | "path": "/", 72 | "httpMethod": "GET", 73 | "requestContext": {"resourcePath": "/", "httpMethod": "GET", "path": "/Prod/"}, 74 | "headers": { 75 | "accept": "text/html,application/xhtml+xml,application/xml;" 76 | "q=0.9,image/webp,image/apng,*/*;q=0.8," 77 | "application/signed-exchange;v=b3;q=0.9", 78 | "accept-encoding": "gzip, deflate, br", 79 | "Host": "70ixmpl4fl.execute-api.us-east-2.amazonaws.com", 80 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " 81 | "AppleWebKit/537.36 (KHTML, like Gecko) " 82 | "Chrome/80.0.3987.132 Safari/537.36", 83 | "X-Amzn-Trace-Id": "Root=1-5e66d96f-7491f09xmpl79d18acf3d050", 84 | }, 85 | "multiValueHeaders": { 86 | "accept": [ 87 | "text/html,application/xhtml+xml,application/xml;" 88 | "q=0.9,image/webp,image/apng,*/*;q=0.8," 89 | "application/signed-exchange;v=b3;q=0.9" 90 | ], 91 | "accept-encoding": ["gzip, deflate, br"], 92 | }, 93 | "queryStringParameters": {"foo": "bar"}, 94 | "multiValueQueryStringParameters": None, 95 | "pathParameters": None, 96 | "stageVariables": None, 97 | "body": None, 98 | "isBase64Encoded": False, 99 | } 100 | example_context = {} 101 | handler = APIGateway(example_event, example_context, {"api_gateway_base_path": "/"}) 102 | 103 | assert isinstance(handler.body, bytes) 104 | assert handler.scope == { 105 | "asgi": {"version": "3.0", "spec_version": "2.0"}, 106 | "aws.context": {}, 107 | "aws.event": example_event, 108 | "client": (None, 0), 109 | "headers": [ 110 | [ 111 | b"accept", 112 | b"text/html,application/xhtml+xml,application/xml;" 113 | b"q=0.9,image/webp,image/apng,*/*;q=0.8," 114 | b"application/signed-exchange;v=b3;q=0.9", 115 | ], 116 | [b"accept-encoding", b"gzip, deflate, br"], 117 | [b"host", b"70ixmpl4fl.execute-api.us-east-2.amazonaws.com"], 118 | [ 119 | b"user-agent", 120 | b"Mozilla/5.0 (Windows NT 10.0; Win64; x64) " 121 | b"AppleWebKit/537.36 (KHTML, like Gecko) " 122 | b"Chrome/80.0.3987.132 " 123 | b"Safari/537.36", 124 | ], 125 | [b"x-amzn-trace-id", b"Root=1-5e66d96f-7491f09xmpl79d18acf3d050"], 126 | ], 127 | "http_version": "1.1", 128 | "method": "GET", 129 | "path": "/", 130 | "query_string": b"foo=bar", 131 | "raw_path": None, 132 | "root_path": "", 133 | "scheme": "https", 134 | "server": ("70ixmpl4fl.execute-api.us-east-2.amazonaws.com", 80), 135 | "type": "http", 136 | } 137 | 138 | 139 | @pytest.mark.parametrize( 140 | "method,path,multi_value_query_parameters,req_body,body_base64_encoded," "query_string,scope_body", 141 | [ 142 | ("GET", "/hello/world", None, None, False, b"", None), 143 | ( 144 | "POST", 145 | "/", 146 | {"name": ["me"]}, 147 | "field1=value1&field2=value2", 148 | False, 149 | b"name=me", 150 | b"field1=value1&field2=value2", 151 | ), 152 | ( 153 | "GET", 154 | "/my/resource", 155 | {"name": ["me", "you"]}, 156 | None, 157 | False, 158 | b"name=me&name=you", 159 | None, 160 | ), 161 | ( 162 | "GET", 163 | "", 164 | {"name": ["me", "you"], "pet": ["dog"]}, 165 | None, 166 | False, 167 | b"name=me&name=you&pet=dog", 168 | None, 169 | ), 170 | # A 1x1 red px gif 171 | ( 172 | "POST", 173 | "/img", 174 | None, 175 | b"R0lGODdhAQABAIABAP8AAAAAACwAAAAAAQABAAACAkQBADs=", 176 | True, 177 | b"", 178 | b"GIF87a\x01\x00\x01\x00\x80\x01\x00\xff\x00\x00\x00\x00\x00," 179 | b"\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;", 180 | ), 181 | ("POST", "/form-submit", None, b"say=Hi&to=Mom", False, b"", b"say=Hi&to=Mom"), 182 | ], 183 | ) 184 | def test_aws_api_gateway_scope_real( 185 | method, 186 | path, 187 | multi_value_query_parameters, 188 | req_body, 189 | body_base64_encoded, 190 | query_string, 191 | scope_body, 192 | ): 193 | event = get_mock_aws_api_gateway_event(method, path, multi_value_query_parameters, req_body, body_base64_encoded) 194 | example_context = {} 195 | handler = APIGateway(event, example_context, {"api_gateway_base_path": "/"}) 196 | 197 | scope_path = path 198 | if scope_path == "": 199 | scope_path = "/" 200 | 201 | assert handler.scope == { 202 | "asgi": {"version": "3.0", "spec_version": "2.0"}, 203 | "aws.context": {}, 204 | "aws.event": event, 205 | "client": ("192.168.100.1", 0), 206 | "headers": [ 207 | [ 208 | b"accept", 209 | b"text/html,application/xhtml+xml,application/xml;q=0.9,image/" b"webp,*/*;q=0.8", 210 | ], 211 | [b"accept-encoding", b"gzip, deflate, lzma, sdch, br"], 212 | [b"accept-language", b"en-US,en;q=0.8"], 213 | [b"cloudfront-forwarded-proto", b"https"], 214 | [b"cloudfront-is-desktop-viewer", b"true"], 215 | [b"cloudfront-is-mobile-viewer", b"false"], 216 | [b"cloudfront-is-smarttv-viewer", b"false"], 217 | [b"cloudfront-is-tablet-viewer", b"false"], 218 | [b"cloudfront-viewer-country", b"US"], 219 | [b"cookie", b"cookie1; cookie2"], 220 | [b"host", b"test.execute-api.us-west-2.amazonaws.com"], 221 | [b"upgrade-insecure-requests", b"1"], 222 | [b"x-forwarded-for", b"192.168.100.1, 192.168.1.1"], 223 | [b"x-forwarded-port", b"443"], 224 | [b"x-forwarded-proto", b"https"], 225 | ], 226 | "http_version": "1.1", 227 | "method": method, 228 | "path": scope_path, 229 | "query_string": query_string, 230 | "raw_path": None, 231 | "root_path": "", 232 | "scheme": "https", 233 | "server": ("test.execute-api.us-west-2.amazonaws.com", 443), 234 | "type": "http", 235 | } 236 | 237 | if handler.body: 238 | assert handler.body == scope_body 239 | else: 240 | assert handler.body == b"" 241 | 242 | 243 | @pytest.mark.parametrize( 244 | "method,path,multi_value_query_parameters,req_body,body_base64_encoded," "query_string,scope_body", 245 | [ 246 | ("GET", "/test/hello", None, None, False, b"", None), 247 | ], 248 | ) 249 | def test_aws_api_gateway_base_path( 250 | method, 251 | path, 252 | multi_value_query_parameters, 253 | req_body, 254 | body_base64_encoded, 255 | query_string, 256 | scope_body, 257 | ): 258 | event = get_mock_aws_api_gateway_event(method, path, multi_value_query_parameters, req_body, body_base64_encoded) 259 | 260 | async def app(scope, receive, send): 261 | assert scope["type"] == "http" 262 | assert scope["path"] == urllib.parse.unquote(event["path"]) 263 | await send( 264 | { 265 | "type": "http.response.start", 266 | "status": 200, 267 | "headers": [[b"content-type", b"text/plain"]], 268 | } 269 | ) 270 | await send({"type": "http.response.body", "body": b"Hello world!"}) 271 | 272 | handler = Mangum(app, lifespan="off", api_gateway_base_path=None) 273 | response = handler(event, {}) 274 | 275 | assert response == { 276 | "body": "Hello world!", 277 | "headers": {"content-type": "text/plain"}, 278 | "multiValueHeaders": {}, 279 | "isBase64Encoded": False, 280 | "statusCode": 200, 281 | } 282 | 283 | async def app(scope, receive, send): 284 | assert scope["type"] == "http" 285 | assert scope["path"] == urllib.parse.unquote(event["path"][len(f"/{api_gateway_base_path}") :]) 286 | await send( 287 | { 288 | "type": "http.response.start", 289 | "status": 200, 290 | "headers": [[b"content-type", b"text/plain"]], 291 | } 292 | ) 293 | await send({"type": "http.response.body", "body": b"Hello world!"}) 294 | 295 | api_gateway_base_path = "test" 296 | handler = Mangum(app, lifespan="off", api_gateway_base_path=api_gateway_base_path) 297 | response = handler(event, {}) 298 | assert response == { 299 | "body": "Hello world!", 300 | "headers": {"content-type": "text/plain"}, 301 | "multiValueHeaders": {}, 302 | "isBase64Encoded": False, 303 | "statusCode": 200, 304 | } 305 | 306 | 307 | @pytest.mark.parametrize( 308 | "method,content_type,raw_res_body,res_body,res_base64_encoded", 309 | [ 310 | ("GET", b"text/plain; charset=utf-8", b"Hello world", "Hello world", False), 311 | # A 1x1 red px gif 312 | ( 313 | "POST", 314 | b"image/gif", 315 | b"GIF87a\x01\x00\x01\x00\x80\x01\x00\xff\x00\x00\x00\x00\x00," 316 | b"\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;", 317 | "R0lGODdhAQABAIABAP8AAAAAACwAAAAAAQABAAACAkQBADs=", 318 | True, 319 | ), 320 | ], 321 | ) 322 | def test_aws_api_gateway_response(method, content_type, raw_res_body, res_body, res_base64_encoded): 323 | async def app(scope, receive, send): 324 | await send( 325 | { 326 | "type": "http.response.start", 327 | "status": 200, 328 | "headers": [[b"content-type", content_type]], 329 | } 330 | ) 331 | await send({"type": "http.response.body", "body": raw_res_body}) 332 | 333 | event = get_mock_aws_api_gateway_event(method, "/test", {}, None, False) 334 | 335 | handler = Mangum(app, lifespan="off") 336 | 337 | response = handler(event, {}) 338 | assert response == { 339 | "statusCode": 200, 340 | "isBase64Encoded": res_base64_encoded, 341 | "headers": {"content-type": content_type.decode()}, 342 | "multiValueHeaders": {}, 343 | "body": res_body, 344 | } 345 | 346 | 347 | def test_aws_api_gateway_response_extra_mime_types(): 348 | content_type = b"application/x-yaml" 349 | utf_res_body = "name: 'John Doe'" 350 | raw_res_body = utf_res_body.encode() 351 | b64_res_body = "bmFtZTogJ0pvaG4gRG9lJw==" 352 | 353 | async def app(scope, receive, send): 354 | await send( 355 | { 356 | "type": "http.response.start", 357 | "status": 200, 358 | "headers": [[b"content-type", content_type]], 359 | } 360 | ) 361 | await send({"type": "http.response.body", "body": raw_res_body}) 362 | 363 | event = get_mock_aws_api_gateway_event("POST", "/test", {}, None, False) 364 | 365 | # Test default behavior 366 | handler = Mangum(app, lifespan="off") 367 | response = handler(event, {}) 368 | assert content_type.decode() not in handler.config["text_mime_types"] 369 | assert response == { 370 | "statusCode": 200, 371 | "isBase64Encoded": True, 372 | "headers": {"content-type": content_type.decode()}, 373 | "multiValueHeaders": {}, 374 | "body": b64_res_body, 375 | } 376 | 377 | # Test with modified text mime types 378 | handler = Mangum(app, lifespan="off") 379 | handler.config["text_mime_types"].append(content_type.decode()) 380 | response = handler(event, {}) 381 | assert response == { 382 | "statusCode": 200, 383 | "isBase64Encoded": False, 384 | "headers": {"content-type": content_type.decode()}, 385 | "multiValueHeaders": {}, 386 | "body": utf_res_body, 387 | } 388 | 389 | 390 | def test_aws_api_gateway_exclude_headers(): 391 | async def app(scope, receive, send): 392 | await send( 393 | { 394 | "type": "http.response.start", 395 | "status": 200, 396 | "headers": [ 397 | [b"content-type", b"text/plain; charset=utf-8"], 398 | [b"x-custom-header", b"test"], 399 | ], 400 | } 401 | ) 402 | await send({"type": "http.response.body", "body": b"Hello world"}) 403 | 404 | event = get_mock_aws_api_gateway_event("GET", "/test", {}, None, False) 405 | 406 | handler = Mangum(app, lifespan="off", exclude_headers=["X-CUSTOM-HEADER"]) 407 | 408 | response = handler(event, {}) 409 | assert response == { 410 | "statusCode": 200, 411 | "isBase64Encoded": False, 412 | "headers": {"content-type": b"text/plain; charset=utf-8".decode()}, 413 | "multiValueHeaders": {}, 414 | "body": "Hello world", 415 | } 416 | -------------------------------------------------------------------------------- /tests/handlers/test_custom.py: -------------------------------------------------------------------------------- 1 | from mangum.types import Headers, LambdaConfig, LambdaContext, LambdaEvent, Scope 2 | 3 | 4 | class CustomHandler: 5 | @classmethod 6 | def infer(cls, event: LambdaEvent, context: LambdaContext, config: LambdaConfig) -> bool: 7 | return "my-custom-key" in event 8 | 9 | def __init__(self, event: LambdaEvent, context: LambdaContext, config: LambdaConfig) -> None: 10 | self.event = event 11 | self.context = context 12 | self.config = config 13 | 14 | @property 15 | def body(self) -> bytes: 16 | return b"My request body" 17 | 18 | @property 19 | def scope(self) -> Scope: 20 | headers: dict[str, str] = {} 21 | return { 22 | "type": "http", 23 | "http_version": "1.1", 24 | "method": "GET", 25 | "headers": [[k.encode(), v.encode()] for k, v in headers.items()], 26 | "path": "/", 27 | "raw_path": None, 28 | "root_path": "", 29 | "scheme": "https", 30 | "query_string": b"", 31 | "server": ("mangum", 8080), 32 | "client": ("127.0.0.1", 0), 33 | "asgi": {"version": "3.0", "spec_version": "2.0"}, 34 | "aws.event": self.event, 35 | "aws.context": self.context, 36 | } 37 | 38 | def __call__(self, *, status: int, headers: Headers, body: bytes) -> dict: 39 | return {"statusCode": status, "headers": {}, "body": body.decode()} 40 | 41 | 42 | def test_custom_handler(): 43 | event = {"my-custom-key": 1} 44 | handler = CustomHandler(event, {}, {"api_gateway_base_path": "/"}) 45 | assert isinstance(handler.body, bytes) 46 | assert handler.scope == { 47 | "asgi": {"version": "3.0", "spec_version": "2.0"}, 48 | "aws.context": {}, 49 | "aws.event": event, 50 | "client": ("127.0.0.1", 0), 51 | "headers": [], 52 | "http_version": "1.1", 53 | "method": "GET", 54 | "path": "/", 55 | "query_string": b"", 56 | "raw_path": None, 57 | "root_path": "", 58 | "scheme": "https", 59 | "server": ("mangum", 8080), 60 | "type": "http", 61 | } 62 | -------------------------------------------------------------------------------- /tests/handlers/test_http_gateway.py: -------------------------------------------------------------------------------- 1 | import urllib.parse 2 | 3 | import pytest 4 | 5 | from mangum import Mangum 6 | from mangum.handlers.api_gateway import HTTPGateway 7 | 8 | 9 | def get_mock_aws_http_gateway_event_v1(method, path, query_parameters, body, body_base64_encoded): 10 | query_string = urllib.parse.urlencode(query_parameters if query_parameters else {}) 11 | return { 12 | "version": "1.0", 13 | "resource": path, 14 | "path": path, 15 | "httpMethod": method, 16 | "headers": { 17 | "accept-encoding": "gzip,deflate", 18 | "x-forwarded-port": "443", 19 | "x-forwarded-proto": "https", 20 | "host": "test.execute-api.us-west-2.amazonaws.com", 21 | }, 22 | "multiValueHeaders": { 23 | "accept-encoding": ["gzip", "deflate"], 24 | "x-forwarded-port": ["443"], 25 | "x-forwarded-proto": ["https"], 26 | "host": ["test.execute-api.us-west-2.amazonaws.com"], 27 | }, 28 | "queryStringParameters": ({k: v[0] for k, v in query_parameters.items()} if query_parameters else {}), 29 | "multiValueQueryStringParameters": ({k: v for k, v in query_parameters.items()} if query_parameters else {}), 30 | "requestContext": { 31 | "accountId": "123456789012", 32 | "apiId": "id", 33 | "authorizer": {"claims": None, "scopes": None}, 34 | "domainName": "id.execute-api.us-east-1.amazonaws.com", 35 | "domainPrefix": "id", 36 | "extendedRequestId": "request-id", 37 | "httpMethod": method, 38 | "identity": { 39 | "accessKey": None, 40 | "accountId": None, 41 | "caller": None, 42 | "cognitoAuthenticationProvider": None, 43 | "cognitoAuthenticationType": None, 44 | "cognitoIdentityId": None, 45 | "cognitoIdentityPoolId": None, 46 | "principalOrgId": None, 47 | "sourceIp": "192.168.100.1", 48 | "user": None, 49 | "userAgent": "user-agent", 50 | "userArn": None, 51 | "clientCert": { 52 | "clientCertPem": "CERT_CONTENT", 53 | "subjectDN": "www.example.com", 54 | "issuerDN": "Example issuer", 55 | "serialNumber": "a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1", 56 | "validity": { 57 | "notBefore": "May 28 12:30:02 2019 GMT", 58 | "notAfter": "Aug 5 09:36:04 2021 GMT", 59 | }, 60 | }, 61 | }, 62 | "path": path, 63 | "protocol": "HTTP/1.1", 64 | "requestId": "id=", 65 | "requestTime": "04/Mar/2020:19:15:17 +0000", 66 | "requestTimeEpoch": 1583349317135, 67 | "resourceId": None, 68 | "resourcePath": path, 69 | "stage": "$default", 70 | }, 71 | "pathParameters": query_string, 72 | "stageVariables": None, 73 | "body": body, 74 | "isBase64Encoded": body_base64_encoded, 75 | } 76 | 77 | 78 | def get_mock_aws_http_gateway_event_v2(method, path, query_parameters, body, body_base64_encoded): 79 | query_string = urllib.parse.urlencode(query_parameters if query_parameters else {}) 80 | return { 81 | "version": "2.0", 82 | "routeKey": "$default", 83 | "rawPath": path, 84 | "rawQueryString": query_string, 85 | "cookies": ["cookie1", "cookie2"], 86 | "headers": { 87 | "accept-encoding": "gzip,deflate", 88 | "x-forwarded-port": "443", 89 | "x-forwarded-proto": "https", 90 | "host": "test.execute-api.us-west-2.amazonaws.com", 91 | }, 92 | "queryStringParameters": ({k: v[0] for k, v in query_parameters.items()} if query_parameters else {}), 93 | "requestContext": { 94 | "accountId": "123456789012", 95 | "apiId": "api-id", 96 | "authorizer": { 97 | "jwt": { 98 | "claims": {"claim1": "value1", "claim2": "value2"}, 99 | "scopes": ["scope1", "scope2"], 100 | } 101 | }, 102 | "domainName": "id.execute-api.us-east-1.amazonaws.com", 103 | "domainPrefix": "id", 104 | "http": { 105 | "method": method, 106 | "path": path, 107 | "protocol": "HTTP/1.1", 108 | "sourceIp": "192.168.100.1", 109 | "userAgent": "agent", 110 | }, 111 | "requestId": "id", 112 | "routeKey": "$default", 113 | "stage": "$default", 114 | "time": "12/Mar/2020:19:03:58 +0000", 115 | "timeEpoch": 1583348638390, 116 | }, 117 | "body": body, 118 | "pathParameters": {"parameter1": "value1"}, 119 | "isBase64Encoded": body_base64_encoded, 120 | "stageVariables": {"stageVariable1": "value1", "stageVariable2": "value2"}, 121 | } 122 | 123 | 124 | def test_aws_http_gateway_scope_basic_v1(): 125 | """ 126 | Test the event from the AWS docs 127 | """ 128 | example_event = { 129 | "version": "1.0", 130 | "resource": "/my/path", 131 | "path": "/my/path", 132 | "httpMethod": "GET", 133 | "headers": {"Header1": "value1", "Header2": "value2"}, 134 | "multiValueHeaders": {"Header1": ["value1"], "Header2": ["value1", "value2"]}, 135 | "queryStringParameters": {"parameter1": "value1", "parameter2": "value"}, 136 | "multiValueQueryStringParameters": { 137 | "parameter1": ["value1", "value2"], 138 | "parameter2": ["value"], 139 | }, 140 | "requestContext": { 141 | "accountId": "123456789012", 142 | "apiId": "id", 143 | "authorizer": {"claims": None, "scopes": None}, 144 | "domainName": "id.execute-api.us-east-1.amazonaws.com", 145 | "domainPrefix": "id", 146 | "extendedRequestId": "request-id", 147 | "httpMethod": "GET", 148 | "identity": { 149 | "accessKey": None, 150 | "accountId": None, 151 | "caller": None, 152 | "cognitoAuthenticationProvider": None, 153 | "cognitoAuthenticationType": None, 154 | "cognitoIdentityId": None, 155 | "cognitoIdentityPoolId": None, 156 | "principalOrgId": None, 157 | "sourceIp": "IP", 158 | "user": None, 159 | "userAgent": "user-agent", 160 | "userArn": None, 161 | "clientCert": { 162 | "clientCertPem": "CERT_CONTENT", 163 | "subjectDN": "www.example.com", 164 | "issuerDN": "Example issuer", 165 | "serialNumber": "a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1", 166 | "validity": { 167 | "notBefore": "May 28 12:30:02 2019 GMT", 168 | "notAfter": "Aug 5 09:36:04 2021 GMT", 169 | }, 170 | }, 171 | }, 172 | "path": "/my/path", 173 | "protocol": "HTTP/1.1", 174 | "requestId": "id=", 175 | "requestTime": "04/Mar/2020:19:15:17 +0000", 176 | "requestTimeEpoch": 1583349317135, 177 | "resourceId": None, 178 | "resourcePath": "/my/path", 179 | "stage": "$default", 180 | }, 181 | "pathParameters": None, 182 | "stageVariables": None, 183 | "body": "Hello from Lambda!", 184 | "isBase64Encoded": False, 185 | } 186 | 187 | example_context = {} 188 | handler = HTTPGateway(example_event, example_context, {"api_gateway_base_path": "/"}) 189 | 190 | assert isinstance(handler.body, bytes) 191 | assert handler.scope == { 192 | "asgi": {"version": "3.0", "spec_version": "2.0"}, 193 | "aws.context": {}, 194 | "aws.event": example_event, 195 | "client": ("IP", 0), 196 | "headers": [[b"header1", b"value1"], [b"header2", b"value1, value2"]], 197 | "http_version": "1.1", 198 | "method": "GET", 199 | "path": "/my/path", 200 | "query_string": b"parameter1=value1¶meter1=value2¶meter2=value", 201 | "raw_path": None, 202 | "root_path": "", 203 | "scheme": "https", 204 | "server": ("mangum", 80), 205 | "type": "http", 206 | } 207 | 208 | 209 | def test_aws_http_gateway_scope_v1_only_non_multi_headers(): 210 | """ 211 | Ensure only queryStringParameters headers still works (unsure if this is possible 212 | from HTTP Gateway) 213 | """ 214 | example_event = get_mock_aws_http_gateway_event_v1("GET", "/test", {"hello": ["world", "life"]}, None, False) 215 | del example_event["multiValueQueryStringParameters"] 216 | example_context = {} 217 | handler = HTTPGateway(example_event, example_context, {"api_gateway_base_path": "/"}) 218 | assert handler.scope["query_string"] == b"hello=world" 219 | 220 | 221 | def test_aws_http_gateway_scope_v1_no_headers(): 222 | """ 223 | Ensure no headers still works (unsure if this is possible from HTTP Gateway) 224 | """ 225 | example_event = get_mock_aws_http_gateway_event_v1("GET", "/test", {"hello": ["world", "life"]}, None, False) 226 | del example_event["multiValueQueryStringParameters"] 227 | del example_event["queryStringParameters"] 228 | example_context = {} 229 | handler = HTTPGateway(example_event, example_context, {"api_gateway_base_path": "/"}) 230 | assert handler.scope["query_string"] == b"" 231 | 232 | 233 | def test_aws_http_gateway_scope_basic_v2(): 234 | """ 235 | Test the event from the AWS docs 236 | """ 237 | example_event = { 238 | "version": "2.0", 239 | "routeKey": "$default", 240 | "rawPath": "/my/path", 241 | "rawQueryString": "parameter1=value1¶meter1=value2¶meter2=value", 242 | "cookies": ["cookie1", "cookie2"], 243 | "headers": {"Header1": "value1", "Header2": "value1,value2"}, 244 | "queryStringParameters": {"parameter1": "value1,value2", "parameter2": "value"}, 245 | "requestContext": { 246 | "accountId": "123456789012", 247 | "apiId": "api-id", 248 | "authentication": { 249 | "clientCert": { 250 | "clientCertPem": "CERT_CONTENT", 251 | "subjectDN": "www.example.com", 252 | "issuerDN": "Example issuer", 253 | "serialNumber": "a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1", 254 | "validity": { 255 | "notBefore": "May 28 12:30:02 2019 GMT", 256 | "notAfter": "Aug 5 09:36:04 2021 GMT", 257 | }, 258 | } 259 | }, 260 | "authorizer": { 261 | "jwt": { 262 | "claims": {"claim1": "value1", "claim2": "value2"}, 263 | "scopes": ["scope1", "scope2"], 264 | } 265 | }, 266 | "domainName": "id.execute-api.us-east-1.amazonaws.com", 267 | "domainPrefix": "id", 268 | "http": { 269 | "method": "POST", 270 | "path": "/my/path", 271 | "protocol": "HTTP/1.1", 272 | "sourceIp": "IP", 273 | "userAgent": "agent", 274 | }, 275 | "requestId": "id", 276 | "routeKey": "$default", 277 | "stage": "$default", 278 | "time": "12/Mar/2020:19:03:58 +0000", 279 | "timeEpoch": 1583348638390, 280 | }, 281 | "body": "Hello from Lambda", 282 | "pathParameters": {"parameter1": "value1"}, 283 | "isBase64Encoded": False, 284 | "stageVariables": {"stageVariable1": "value1", "stageVariable2": "value2"}, 285 | } 286 | example_context = {} 287 | handler = HTTPGateway(example_event, example_context, {"api_gateway_base_path": "/"}) 288 | 289 | assert isinstance(handler.body, bytes) 290 | assert handler.scope == { 291 | "asgi": {"version": "3.0", "spec_version": "2.0"}, 292 | "aws.context": {}, 293 | "aws.event": example_event, 294 | "client": ("IP", 0), 295 | "headers": [ 296 | [b"header1", b"value1"], 297 | [b"header2", b"value1,value2"], 298 | [b"cookie", b"cookie1; cookie2"], 299 | ], 300 | "http_version": "1.1", 301 | "method": "POST", 302 | "path": "/my/path", 303 | "query_string": b"parameter1=value1¶meter1=value2¶meter2=value", 304 | "raw_path": None, 305 | "root_path": "", 306 | "scheme": "https", 307 | "server": ("mangum", 80), 308 | "type": "http", 309 | } 310 | 311 | 312 | @pytest.mark.parametrize( 313 | "method,path,query_parameters,req_body,body_base64_encoded,query_string,scope_body", 314 | [ 315 | ("GET", "/my/test/path", None, None, False, b"", None), 316 | ("GET", "", {"name": "me"}, None, False, b"name=me", None), 317 | # A 1x1 red px gif 318 | ( 319 | "POST", 320 | "/img", 321 | None, 322 | b"R0lGODdhAQABAIABAP8AAAAAACwAAAAAAQABAAACAkQBADs=", 323 | True, 324 | b"", 325 | b"GIF87a\x01\x00\x01\x00\x80\x01\x00\xff\x00\x00\x00\x00\x00," 326 | b"\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;", 327 | ), 328 | ("POST", "/form-submit", None, b"say=Hi&to=Mom", False, b"", b"say=Hi&to=Mom"), 329 | ], 330 | ) 331 | def test_aws_http_gateway_scope_real_v1( 332 | method, 333 | path, 334 | query_parameters, 335 | req_body, 336 | body_base64_encoded, 337 | query_string, 338 | scope_body, 339 | ) -> None: 340 | event = get_mock_aws_http_gateway_event_v1(method, path, query_parameters, req_body, body_base64_encoded) 341 | example_context = {} 342 | handler = HTTPGateway(event, example_context, {"api_gateway_base_path": "/"}) 343 | 344 | scope_path = path 345 | if scope_path == "": 346 | scope_path = "/" 347 | 348 | assert handler.scope == { 349 | "asgi": {"version": "3.0", "spec_version": "2.0"}, 350 | "aws.context": {}, 351 | "aws.event": event, 352 | "client": ("192.168.100.1", 0), 353 | "headers": [ 354 | [b"accept-encoding", b"gzip, deflate"], 355 | [b"x-forwarded-port", b"443"], 356 | [b"x-forwarded-proto", b"https"], 357 | [b"host", b"test.execute-api.us-west-2.amazonaws.com"], 358 | ], 359 | "http_version": "1.1", 360 | "method": method, 361 | "path": scope_path, 362 | "query_string": query_string, 363 | "raw_path": None, 364 | "root_path": "", 365 | "scheme": "https", 366 | "server": ("test.execute-api.us-west-2.amazonaws.com", 443), 367 | "type": "http", 368 | } 369 | 370 | if handler.body: 371 | assert handler.body == scope_body 372 | else: 373 | assert handler.body == b"" 374 | 375 | 376 | @pytest.mark.parametrize( 377 | "method,path,query_parameters,req_body,body_base64_encoded,query_string,scope_body", 378 | [ 379 | ("GET", "/my/test/path", None, None, False, b"", None), 380 | ("GET", "", {"name": "me"}, None, False, b"name=me", None), 381 | # A 1x1 red px gif 382 | ( 383 | "POST", 384 | "/img", 385 | None, 386 | b"R0lGODdhAQABAIABAP8AAAAAACwAAAAAAQABAAACAkQBADs=", 387 | True, 388 | b"", 389 | b"GIF87a\x01\x00\x01\x00\x80\x01\x00\xff\x00\x00\x00\x00\x00," 390 | b"\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;", 391 | ), 392 | ("POST", "/form-submit", None, b"say=Hi&to=Mom", False, b"", b"say=Hi&to=Mom"), 393 | ], 394 | ) 395 | def test_aws_http_gateway_scope_real_v2( 396 | method, 397 | path, 398 | query_parameters, 399 | req_body, 400 | body_base64_encoded, 401 | query_string, 402 | scope_body, 403 | ) -> None: 404 | event = get_mock_aws_http_gateway_event_v2(method, path, query_parameters, req_body, body_base64_encoded) 405 | example_context = {} 406 | handler = HTTPGateway(event, example_context, {"api_gateway_base_path": "/"}) 407 | 408 | scope_path = path 409 | if scope_path == "": 410 | scope_path = "/" 411 | 412 | assert handler.scope == { 413 | "asgi": {"version": "3.0", "spec_version": "2.0"}, 414 | "aws.context": {}, 415 | "aws.event": event, 416 | "client": ("192.168.100.1", 0), 417 | "headers": [ 418 | [b"accept-encoding", b"gzip,deflate"], 419 | [b"x-forwarded-port", b"443"], 420 | [b"x-forwarded-proto", b"https"], 421 | [b"host", b"test.execute-api.us-west-2.amazonaws.com"], 422 | [b"cookie", b"cookie1; cookie2"], 423 | ], 424 | "http_version": "1.1", 425 | "method": method, 426 | "path": scope_path, 427 | "query_string": query_string, 428 | "raw_path": None, 429 | "root_path": "", 430 | "scheme": "https", 431 | "server": ("test.execute-api.us-west-2.amazonaws.com", 443), 432 | "type": "http", 433 | } 434 | 435 | if handler.body: 436 | assert handler.body == scope_body 437 | else: 438 | assert handler.body == b"" 439 | 440 | 441 | @pytest.mark.parametrize( 442 | "method,content_type,raw_res_body,res_body,res_base64_encoded", 443 | [ 444 | ("GET", b"text/plain; charset=utf-8", b"Hello world", "Hello world", False), 445 | ( 446 | "GET", 447 | b"application/json", 448 | b'{"hello": "world", "foo": true}', 449 | '{"hello": "world", "foo": true}', 450 | False, 451 | ), 452 | ("GET", None, b"Hello world", "SGVsbG8gd29ybGQ=", True), 453 | ( 454 | "GET", 455 | None, 456 | b'{"hello": "world", "foo": true}', 457 | "eyJoZWxsbyI6ICJ3b3JsZCIsICJmb28iOiB0cnVlfQ==", 458 | True, 459 | ), 460 | # A 1x1 red px gif 461 | ( 462 | "POST", 463 | b"image/gif", 464 | b"GIF87a\x01\x00\x01\x00\x80\x01\x00\xff\x00\x00\x00\x00\x00," 465 | b"\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;", 466 | "R0lGODdhAQABAIABAP8AAAAAACwAAAAAAQABAAACAkQBADs=", 467 | True, 468 | ), 469 | ], 470 | ) 471 | def test_aws_http_gateway_response_v1(method, content_type, raw_res_body, res_body, res_base64_encoded): 472 | """ 473 | Test response types make sense. v1 does less magic than v2. 474 | """ 475 | 476 | async def app(scope, receive, send): 477 | headers = [] 478 | if content_type is not None: 479 | headers.append([b"content-type", content_type]) 480 | 481 | await send( 482 | { 483 | "type": "http.response.start", 484 | "status": 200, 485 | "headers": headers, 486 | } 487 | ) 488 | await send({"type": "http.response.body", "body": raw_res_body}) 489 | 490 | event = get_mock_aws_http_gateway_event_v1(method, "/test", {}, None, False) 491 | 492 | handler = Mangum(app, lifespan="off") 493 | 494 | response = handler(event, {}) 495 | 496 | res_headers = {} 497 | if content_type is not None: 498 | res_headers = {"content-type": content_type.decode()} 499 | 500 | assert response == { 501 | "statusCode": 200, 502 | "isBase64Encoded": res_base64_encoded, 503 | "headers": res_headers, 504 | "multiValueHeaders": {}, 505 | "body": res_body, 506 | } 507 | 508 | 509 | @pytest.mark.parametrize( 510 | "method,content_type,raw_res_body,res_body,res_base64_encoded", 511 | [ 512 | ("GET", b"text/plain; charset=utf-8", b"Hello world", "Hello world", False), 513 | ( 514 | "GET", 515 | b"application/json", 516 | b'{"hello": "world", "foo": true}', 517 | '{"hello": "world", "foo": true}', 518 | False, 519 | ), 520 | ("GET", None, b"Hello world", "Hello world", False), 521 | ( 522 | "GET", 523 | None, 524 | b'{"hello": "world", "foo": true}', 525 | '{"hello": "world", "foo": true}', 526 | False, 527 | ), 528 | # A 1x1 red px gif 529 | ( 530 | "POST", 531 | b"image/gif", 532 | b"GIF87a\x01\x00\x01\x00\x80\x01\x00\xff\x00\x00\x00\x00\x00," 533 | b"\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;", 534 | "R0lGODdhAQABAIABAP8AAAAAACwAAAAAAQABAAACAkQBADs=", 535 | True, 536 | ), 537 | ], 538 | ) 539 | def test_aws_http_gateway_response_v2(method, content_type, raw_res_body, res_body, res_base64_encoded): 540 | async def app(scope, receive, send): 541 | headers = [] 542 | if content_type is not None: 543 | headers.append([b"content-type", content_type]) 544 | 545 | await send( 546 | { 547 | "type": "http.response.start", 548 | "status": 200, 549 | "headers": headers, 550 | } 551 | ) 552 | await send({"type": "http.response.body", "body": raw_res_body}) 553 | 554 | event = get_mock_aws_http_gateway_event_v2(method, "/test", {}, None, False) 555 | 556 | handler = Mangum(app, lifespan="off") 557 | 558 | response = handler(event, {}) 559 | 560 | if content_type is None: 561 | content_type = b"application/json" 562 | assert response == { 563 | "statusCode": 200, 564 | "isBase64Encoded": res_base64_encoded, 565 | "headers": {"content-type": content_type.decode()}, 566 | "body": res_body, 567 | } 568 | 569 | 570 | def test_aws_http_gateway_response_v1_extra_mime_types(): 571 | content_type = b"application/x-yaml" 572 | utf_res_body = "name: 'John Doe'" 573 | raw_res_body = utf_res_body.encode() 574 | b64_res_body = "bmFtZTogJ0pvaG4gRG9lJw==" 575 | 576 | async def app(scope, receive, send): 577 | headers = [] 578 | if content_type is not None: 579 | headers.append([b"content-type", content_type]) 580 | 581 | await send( 582 | { 583 | "type": "http.response.start", 584 | "status": 200, 585 | "headers": headers, 586 | } 587 | ) 588 | await send({"type": "http.response.body", "body": raw_res_body}) 589 | 590 | event = get_mock_aws_http_gateway_event_v1("POST", "/test", {}, None, False) 591 | 592 | # Test default behavior 593 | handler = Mangum(app, lifespan="off") 594 | response = handler(event, {}) 595 | assert content_type.decode() not in handler.config["text_mime_types"] 596 | assert response == { 597 | "statusCode": 200, 598 | "isBase64Encoded": True, 599 | "headers": {"content-type": content_type.decode()}, 600 | "multiValueHeaders": {}, 601 | "body": b64_res_body, 602 | } 603 | 604 | # Test with modified text mime types 605 | handler = Mangum(app, lifespan="off") 606 | handler.config["text_mime_types"].append(content_type.decode()) 607 | response = handler(event, {}) 608 | assert response == { 609 | "statusCode": 200, 610 | "isBase64Encoded": False, 611 | "headers": {"content-type": content_type.decode()}, 612 | "multiValueHeaders": {}, 613 | "body": utf_res_body, 614 | } 615 | 616 | 617 | def test_aws_http_gateway_response_v2_extra_mime_types(): 618 | content_type = b"application/x-yaml" 619 | utf_res_body = "name: 'John Doe'" 620 | raw_res_body = utf_res_body.encode() 621 | b64_res_body = "bmFtZTogJ0pvaG4gRG9lJw==" 622 | 623 | async def app(scope, receive, send): 624 | headers = [] 625 | if content_type is not None: 626 | headers.append([b"content-type", content_type]) 627 | 628 | await send( 629 | { 630 | "type": "http.response.start", 631 | "status": 200, 632 | "headers": headers, 633 | } 634 | ) 635 | await send({"type": "http.response.body", "body": raw_res_body}) 636 | 637 | event = get_mock_aws_http_gateway_event_v2("POST", "/test", {}, None, False) 638 | 639 | # Test default behavior 640 | handler = Mangum(app, lifespan="off") 641 | response = handler(event, {}) 642 | assert content_type.decode() not in handler.config["text_mime_types"] 643 | assert response == { 644 | "statusCode": 200, 645 | "isBase64Encoded": True, 646 | "headers": {"content-type": content_type.decode()}, 647 | "body": b64_res_body, 648 | } 649 | 650 | # Test with modified text mime types 651 | handler = Mangum(app, lifespan="off") 652 | handler.config["text_mime_types"].append(content_type.decode()) 653 | response = handler(event, {}) 654 | assert response == { 655 | "statusCode": 200, 656 | "isBase64Encoded": False, 657 | "headers": {"content-type": content_type.decode()}, 658 | "body": utf_res_body, 659 | } 660 | -------------------------------------------------------------------------------- /tests/handlers/test_lambda_at_edge.py: -------------------------------------------------------------------------------- 1 | import urllib.parse 2 | 3 | import pytest 4 | 5 | from mangum import Mangum 6 | from mangum.handlers.lambda_at_edge import LambdaAtEdge 7 | 8 | 9 | def mock_lambda_at_edge_event(method, path, multi_value_query_parameters, body, body_base64_encoded): 10 | headers_raw = { 11 | "accept-encoding": "gzip,deflate", 12 | "x-forwarded-port": "443", 13 | "x-forwarded-for": "192.168.100.1", 14 | "x-forwarded-proto": "https", 15 | "host": "test.execute-api.us-west-2.amazonaws.com", 16 | } 17 | headers = {} 18 | for key, value in headers_raw.items(): 19 | headers[key.lower()] = [{"key": key, "value": value}] 20 | 21 | event = { 22 | "Records": [ 23 | { 24 | "cf": { 25 | "config": { 26 | "distributionDomainName": "mock-distribution.local.localhost", 27 | "distributionId": "ABC123DEF456G", 28 | "eventType": "origin-request", 29 | "requestId": "lBEBo2N0JKYUP2JXwn_4am2xAXB2GzcL2FlwXI8G59PA8wghF2ImFQ==", 30 | }, 31 | "request": { 32 | "clientIp": "192.168.100.1", 33 | "headers": headers, 34 | "method": method, 35 | "origin": { 36 | "custom": { 37 | "customHeaders": { 38 | "x-lae-env-custom-var": [ 39 | { 40 | "key": "x-lae-env-custom-var", 41 | "value": "environment variable", 42 | } 43 | ], 44 | }, 45 | "domainName": "www.example.com", 46 | "keepaliveTimeout": 5, 47 | "path": "", 48 | "port": 80, 49 | "protocol": "http", 50 | "readTimeout": 30, 51 | "sslProtocols": ["TLSv1", "TLSv1.1", "TLSv1.2"], 52 | } 53 | }, 54 | "querystring": urllib.parse.urlencode( 55 | (multi_value_query_parameters if multi_value_query_parameters else {}), 56 | doseq=True, 57 | ), 58 | "uri": path, 59 | }, 60 | } 61 | } 62 | ] 63 | } 64 | 65 | if body is not None: 66 | event["Records"][0]["cf"]["request"]["body"] = { 67 | "inputTruncated": False, 68 | "action": "read-only", 69 | "encoding": "base64" if body_base64_encoded else "text", 70 | "data": body, 71 | } 72 | return event 73 | 74 | 75 | def test_aws_cf_lambda_at_edge_scope_basic(): 76 | """ 77 | Test the event from the AWS docs 78 | """ 79 | example_event = { 80 | "Records": [ 81 | { 82 | "cf": { 83 | "config": { 84 | "distributionDomainName": "d111111abcdef8.cloudfront.net", 85 | "distributionId": "EDFDVBD6EXAMPLE", 86 | "eventType": "origin-request", 87 | "requestId": "4TyzHTaYWb1GX1qTfsHhEqV6HUDd_BzoBZnwfnvQc_1oF26ClkoUSEQ==", 88 | }, 89 | "request": { 90 | "clientIp": "203.0.113.178", 91 | "headers": { 92 | "x-forwarded-for": [{"key": "X-Forwarded-For", "value": "203.0.113.178"}], 93 | "user-agent": [{"key": "User-Agent", "value": "Amazon CloudFront"}], 94 | "via": [ 95 | { 96 | "key": "Via", 97 | "value": "2.0 2afae0d44e2540f472c0635ab62c232b.cloudfront.net (CloudFront)", 98 | } 99 | ], 100 | "host": [{"key": "Host", "value": "example.org"}], 101 | "cache-control": [ 102 | { 103 | "key": "Cache-Control", 104 | "value": "no-cache, cf-no-cache", 105 | } 106 | ], 107 | }, 108 | "method": "GET", 109 | "origin": { 110 | "custom": { 111 | "customHeaders": {}, 112 | "domainName": "example.org", 113 | "keepaliveTimeout": 5, 114 | "path": "", 115 | "port": 443, 116 | "protocol": "https", 117 | "readTimeout": 30, 118 | "sslProtocols": ["TLSv1", "TLSv1.1", "TLSv1.2"], 119 | } 120 | }, 121 | "querystring": "", 122 | "uri": "/", 123 | }, 124 | } 125 | } 126 | ] 127 | } 128 | example_context = {} 129 | handler = LambdaAtEdge(example_event, example_context, {"api_gateway_base_path": "/"}) 130 | 131 | assert isinstance(handler.body, bytes) 132 | assert handler.scope == { 133 | "asgi": {"version": "3.0", "spec_version": "2.0"}, 134 | "aws.context": {}, 135 | "aws.event": example_event, 136 | "client": ("203.0.113.178", 0), 137 | "headers": [ 138 | [b"x-forwarded-for", b"203.0.113.178"], 139 | [b"user-agent", b"Amazon CloudFront"], 140 | [ 141 | b"via", 142 | b"2.0 2afae0d44e2540f472c0635ab62c232b.cloudfront.net (CloudFront)", 143 | ], 144 | [b"host", b"example.org"], 145 | [b"cache-control", b"no-cache, cf-no-cache"], 146 | ], 147 | "http_version": "1.1", 148 | "method": "GET", 149 | "path": "/", 150 | "query_string": b"", 151 | "raw_path": None, 152 | "root_path": "", 153 | "scheme": "https", 154 | "server": ("example.org", 80), 155 | "type": "http", 156 | } 157 | 158 | 159 | @pytest.mark.parametrize( 160 | "method,path,multi_value_query_parameters,req_body," "body_base64_encoded,query_string,scope_body", 161 | [ 162 | ("GET", "/hello/world", None, None, False, b"", None), 163 | ( 164 | "POST", 165 | "/", 166 | {"name": ["me"]}, 167 | "field1=value1&field2=value2", 168 | False, 169 | b"name=me", 170 | b"field1=value1&field2=value2", 171 | ), 172 | ( 173 | "GET", 174 | "/my/resource", 175 | {"name": ["me", "you"]}, 176 | None, 177 | False, 178 | b"name=me&name=you", 179 | None, 180 | ), 181 | ( 182 | "GET", 183 | "", 184 | {"name": ["me", "you"], "pet": ["dog"]}, 185 | None, 186 | False, 187 | b"name=me&name=you&pet=dog", 188 | None, 189 | ), 190 | # A 1x1 red px gif 191 | ( 192 | "POST", 193 | "/img", 194 | None, 195 | b"R0lGODdhAQABAIABAP8AAAAAACwAAAAAAQABAAACAkQBADs=", 196 | True, 197 | b"", 198 | b"GIF87a\x01\x00\x01\x00\x80\x01\x00\xff\x00\x00\x00\x00\x00," 199 | b"\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;", 200 | ), 201 | ("POST", "/form-submit", None, b"say=Hi&to=Mom", False, b"", b"say=Hi&to=Mom"), 202 | ], 203 | ) 204 | def test_aws_api_gateway_scope_real( 205 | method, 206 | path, 207 | multi_value_query_parameters, 208 | req_body, 209 | body_base64_encoded, 210 | query_string, 211 | scope_body, 212 | ): 213 | event = mock_lambda_at_edge_event(method, path, multi_value_query_parameters, req_body, body_base64_encoded) 214 | example_context = {} 215 | handler = LambdaAtEdge(event, example_context, {"api_gateway_base_path": "/"}) 216 | 217 | assert handler.scope == { 218 | "asgi": {"version": "3.0", "spec_version": "2.0"}, 219 | "aws.context": {}, 220 | "aws.event": event, 221 | "client": ("192.168.100.1", 0), 222 | "headers": [ 223 | [b"accept-encoding", b"gzip,deflate"], 224 | [b"x-forwarded-port", b"443"], 225 | [b"x-forwarded-for", b"192.168.100.1"], 226 | [b"x-forwarded-proto", b"https"], 227 | [b"host", b"test.execute-api.us-west-2.amazonaws.com"], 228 | ], 229 | "http_version": "1.1", 230 | "method": method, 231 | "path": path, 232 | "query_string": query_string, 233 | "raw_path": None, 234 | "root_path": "", 235 | "scheme": "https", 236 | "server": ("test.execute-api.us-west-2.amazonaws.com", 443), 237 | "type": "http", 238 | } 239 | 240 | if handler.body: 241 | assert handler.body == scope_body 242 | else: 243 | assert handler.body == b"" 244 | 245 | 246 | @pytest.mark.parametrize( 247 | "method,content_type,raw_res_body,res_body,res_base64_encoded", 248 | [ 249 | ("GET", b"text/plain; charset=utf-8", b"Hello world", "Hello world", False), 250 | # A 1x1 red px gif 251 | ( 252 | "POST", 253 | b"image/gif", 254 | b"GIF87a\x01\x00\x01\x00\x80\x01\x00\xff\x00\x00\x00\x00\x00," 255 | b"\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;", 256 | "R0lGODdhAQABAIABAP8AAAAAACwAAAAAAQABAAACAkQBADs=", 257 | True, 258 | ), 259 | ], 260 | ) 261 | def test_aws_lambda_at_edge_response(method, content_type, raw_res_body, res_body, res_base64_encoded): 262 | async def app(scope, receive, send): 263 | await send( 264 | { 265 | "type": "http.response.start", 266 | "status": 200, 267 | "headers": [[b"content-type", content_type]], 268 | } 269 | ) 270 | await send({"type": "http.response.body", "body": raw_res_body}) 271 | 272 | event = mock_lambda_at_edge_event(method, "/test", {}, None, False) 273 | 274 | handler = Mangum(app, lifespan="off") 275 | 276 | response = handler(event, {}) 277 | assert response == { 278 | "status": 200, 279 | "isBase64Encoded": res_base64_encoded, 280 | "headers": {"content-type": [{"key": "content-type", "value": content_type.decode()}]}, 281 | "body": res_body, 282 | } 283 | 284 | 285 | def test_aws_lambda_at_edge_response_extra_mime_types(): 286 | content_type = b"application/x-yaml" 287 | utf_res_body = "name: 'John Doe'" 288 | raw_res_body = utf_res_body.encode() 289 | b64_res_body = "bmFtZTogJ0pvaG4gRG9lJw==" 290 | 291 | async def app(scope, receive, send): 292 | await send( 293 | { 294 | "type": "http.response.start", 295 | "status": 200, 296 | "headers": [[b"content-type", content_type]], 297 | } 298 | ) 299 | await send({"type": "http.response.body", "body": raw_res_body}) 300 | 301 | event = mock_lambda_at_edge_event("POST", "/test", {}, None, False) 302 | 303 | # Test default behavior 304 | handler = Mangum(app, lifespan="off") 305 | response = handler(event, {}) 306 | assert content_type.decode() not in handler.config["text_mime_types"] 307 | assert response == { 308 | "status": 200, 309 | "isBase64Encoded": True, 310 | "headers": {"content-type": [{"key": "content-type", "value": content_type.decode()}]}, 311 | "body": b64_res_body, 312 | } 313 | 314 | # Test with modified text mime types 315 | handler = Mangum(app, lifespan="off") 316 | handler.config["text_mime_types"].append(content_type.decode()) 317 | response = handler(event, {}) 318 | assert response == { 319 | "status": 200, 320 | "isBase64Encoded": False, 321 | "headers": {"content-type": [{"key": "content-type", "value": content_type.decode()}]}, 322 | "body": utf_res_body, 323 | } 324 | 325 | 326 | def test_aws_lambda_at_edge_exclude_(): 327 | async def app(scope, receive, send): 328 | await send( 329 | { 330 | "type": "http.response.start", 331 | "status": 200, 332 | "headers": [ 333 | [b"content-type", b"text/plain; charset=utf-8"], 334 | [b"x-custom-header", b"test"], 335 | ], 336 | } 337 | ) 338 | await send({"type": "http.response.body", "body": b"Hello world"}) 339 | 340 | event = mock_lambda_at_edge_event("GET", "/test", {}, None, False) 341 | 342 | handler = Mangum(app, lifespan="off", exclude_headers=["x-custom-header"]) 343 | 344 | response = handler(event, {}) 345 | assert response == { 346 | "status": 200, 347 | "isBase64Encoded": False, 348 | "headers": {"content-type": [{"key": "content-type", "value": b"text/plain; charset=utf-8".decode()}]}, 349 | "body": "Hello world", 350 | } 351 | -------------------------------------------------------------------------------- /tests/test_adapter.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from mangum import Mangum 4 | from mangum.adapter import DEFAULT_TEXT_MIME_TYPES 5 | from mangum.exceptions import ConfigurationError 6 | from mangum.types import Receive, Scope, Send 7 | 8 | 9 | async def app(scope: Scope, receive: Receive, send: Send): ... 10 | 11 | 12 | def test_default_settings(): 13 | handler = Mangum(app) 14 | assert handler.lifespan == "auto" 15 | assert handler.config["api_gateway_base_path"] == "/" 16 | assert sorted(handler.config["text_mime_types"]) == sorted(DEFAULT_TEXT_MIME_TYPES) 17 | assert handler.config["exclude_headers"] == [] 18 | 19 | 20 | @pytest.mark.parametrize( 21 | "arguments,message", 22 | [ 23 | ( 24 | {"lifespan": "unknown"}, 25 | "Invalid argument supplied for `lifespan`. Choices are: auto|on|off", 26 | ), 27 | ], 28 | ) 29 | def test_invalid_options(arguments, message): 30 | with pytest.raises(ConfigurationError) as exc: 31 | Mangum(app, **arguments) 32 | 33 | assert str(exc.value) == message 34 | -------------------------------------------------------------------------------- /tests/test_http.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import base64 4 | import gzip 5 | import json 6 | 7 | import brotli 8 | import pytest 9 | from brotli_asgi import BrotliMiddleware 10 | from starlette.applications import Starlette 11 | from starlette.middleware.gzip import GZipMiddleware 12 | from starlette.responses import PlainTextResponse 13 | 14 | from mangum import Mangum 15 | 16 | 17 | @pytest.mark.parametrize( 18 | "mock_aws_api_gateway_event", 19 | [["GET", None, {"name": ["me", "you"]}]], 20 | indirect=True, 21 | ) 22 | def test_http_response(mock_aws_api_gateway_event) -> None: 23 | async def app(scope, receive, send): 24 | assert scope == { 25 | "asgi": {"version": "3.0", "spec_version": "2.0"}, 26 | "aws.context": {}, 27 | "aws.event": { 28 | "body": None, 29 | "headers": { 30 | "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", 31 | "Accept-Encoding": "gzip, deflate, lzma, sdch, " "br", 32 | "Accept-Language": "en-US,en;q=0.8", 33 | "CloudFront-Forwarded-Proto": "https", 34 | "CloudFront-Is-Desktop-Viewer": "true", 35 | "CloudFront-Is-Mobile-Viewer": "false", 36 | "CloudFront-Is-SmartTV-Viewer": "false", 37 | "CloudFront-Is-Tablet-Viewer": "false", 38 | "CloudFront-Viewer-Country": "US", 39 | "Cookie": "cookie1; cookie2", 40 | "Host": "test.execute-api.us-west-2.amazonaws.com", 41 | "Upgrade-Insecure-Requests": "1", 42 | "X-Forwarded-For": "192.168.100.1, 192.168.1.1", 43 | "X-Forwarded-Port": "443", 44 | "X-Forwarded-Proto": "https", 45 | }, 46 | "httpMethod": "GET", 47 | "path": "/test/hello", 48 | "pathParameters": {"proxy": "hello"}, 49 | "queryStringParameters": {"name": "me"}, 50 | "multiValueQueryStringParameters": {"name": ["me", "you"]}, 51 | "requestContext": { 52 | "accountId": "123456789012", 53 | "apiId": "123", 54 | "httpMethod": "GET", 55 | "identity": { 56 | "accountId": "", 57 | "apiKey": "", 58 | "caller": "", 59 | "cognitoAuthenticationProvider": "", 60 | "cognitoAuthenticationType": "", 61 | "cognitoIdentityId": "", 62 | "cognitoIdentityPoolId": "", 63 | "sourceIp": "192.168.100.1", 64 | "user": "", 65 | "userAgent": "Mozilla/5.0 " 66 | "(Macintosh; " 67 | "Intel Mac OS " 68 | "X 10_11_6) " 69 | "AppleWebKit/537.36 " 70 | "(KHTML, like " 71 | "Gecko) " 72 | "Chrome/52.0.2743.82 " 73 | "Safari/537.36 " 74 | "OPR/39.0.2256.48", 75 | "userArn": "", 76 | }, 77 | "requestId": "41b45ea3-70b5-11e6-b7bd-69b5aaebc7d9", 78 | "resourceId": "us4z18", 79 | "resourcePath": "/{proxy+}", 80 | "stage": "Prod", 81 | }, 82 | "resource": "/{proxy+}", 83 | "stageVariables": {"stageVarName": "stageVarValue"}, 84 | }, 85 | "client": ("192.168.100.1", 0), 86 | "headers": [ 87 | [ 88 | b"accept", 89 | b"text/html,application/xhtml+xml,application/xml;q=0.9,image/" b"webp,*/*;q=0.8", 90 | ], 91 | [b"accept-encoding", b"gzip, deflate, lzma, sdch, br"], 92 | [b"accept-language", b"en-US,en;q=0.8"], 93 | [b"cloudfront-forwarded-proto", b"https"], 94 | [b"cloudfront-is-desktop-viewer", b"true"], 95 | [b"cloudfront-is-mobile-viewer", b"false"], 96 | [b"cloudfront-is-smarttv-viewer", b"false"], 97 | [b"cloudfront-is-tablet-viewer", b"false"], 98 | [b"cloudfront-viewer-country", b"US"], 99 | [b"cookie", b"cookie1; cookie2"], 100 | [b"host", b"test.execute-api.us-west-2.amazonaws.com"], 101 | [b"upgrade-insecure-requests", b"1"], 102 | [b"x-forwarded-for", b"192.168.100.1, 192.168.1.1"], 103 | [b"x-forwarded-port", b"443"], 104 | [b"x-forwarded-proto", b"https"], 105 | ], 106 | "http_version": "1.1", 107 | "method": "GET", 108 | "path": "/test/hello", 109 | "query_string": b"name=me&name=you", 110 | "raw_path": None, 111 | "root_path": "", 112 | "scheme": "https", 113 | "server": ("test.execute-api.us-west-2.amazonaws.com", 443), 114 | "type": "http", 115 | } 116 | await send( 117 | { 118 | "type": "http.response.start", 119 | "status": 200, 120 | "headers": [ 121 | [b"content-type", b"text/plain; charset=utf-8"], 122 | [b"set-cookie", b"cookie1=cookie1; Secure"], 123 | [b"set-cookie", b"cookie2=cookie2; Secure"], 124 | ], 125 | } 126 | ) 127 | await send({"type": "http.response.body", "body": b"Hello, world!"}) 128 | 129 | handler = Mangum(app, lifespan="off") 130 | response = handler(mock_aws_api_gateway_event, {}) 131 | assert response == { 132 | "statusCode": 200, 133 | "isBase64Encoded": False, 134 | "headers": {"content-type": "text/plain; charset=utf-8"}, 135 | "multiValueHeaders": {"set-cookie": ["cookie1=cookie1; Secure", "cookie2=cookie2; Secure"]}, 136 | "body": "Hello, world!", 137 | } 138 | 139 | 140 | @pytest.mark.parametrize("mock_aws_api_gateway_event", [["GET", None, None]], indirect=True) 141 | def test_http_exception_mid_response(mock_aws_api_gateway_event) -> None: 142 | async def app(scope, receive, send): 143 | await send({"type": "http.response.start", "status": 200}) 144 | raise Exception() 145 | 146 | handler = Mangum(app, lifespan="off") 147 | response = handler(mock_aws_api_gateway_event, {}) 148 | 149 | assert response == { 150 | "body": "Internal Server Error", 151 | "headers": {"content-type": "text/plain; charset=utf-8"}, 152 | "isBase64Encoded": False, 153 | "multiValueHeaders": {}, 154 | "statusCode": 500, 155 | } 156 | 157 | 158 | @pytest.mark.parametrize("mock_aws_api_gateway_event", [["GET", None, None]], indirect=True) 159 | def test_http_exception_handler(mock_aws_api_gateway_event) -> None: 160 | path = mock_aws_api_gateway_event["path"] 161 | app = Starlette() 162 | 163 | @app.exception_handler(Exception) 164 | async def all_exceptions(request, exc): 165 | return PlainTextResponse(content="Error!", status_code=500) 166 | 167 | @app.route(path) 168 | def homepage(request): 169 | raise Exception() 170 | return PlainTextResponse("Hello, world!") 171 | 172 | handler = Mangum(app) 173 | response = handler(mock_aws_api_gateway_event, {}) 174 | 175 | assert response == { 176 | "body": "Error!", 177 | "headers": {"content-length": "6", "content-type": "text/plain; charset=utf-8"}, 178 | "multiValueHeaders": {}, 179 | "isBase64Encoded": False, 180 | "statusCode": 500, 181 | } 182 | 183 | 184 | @pytest.mark.parametrize("mock_aws_api_gateway_event", [["GET", "", None]], indirect=True) 185 | def test_http_cycle_state(mock_aws_api_gateway_event) -> None: 186 | async def app(scope, receive, send): 187 | assert scope["type"] == "http" 188 | await send({"type": "http.response.body", "body": b"Hello, world!"}) 189 | 190 | handler = Mangum(app, lifespan="off") 191 | response = handler(mock_aws_api_gateway_event, {}) 192 | assert response == { 193 | "body": "Internal Server Error", 194 | "headers": {"content-type": "text/plain; charset=utf-8"}, 195 | "multiValueHeaders": {}, 196 | "isBase64Encoded": False, 197 | "statusCode": 500, 198 | } 199 | 200 | async def app(scope, receive, send): 201 | assert scope["type"] == "http" 202 | await send({"type": "http.response.start", "status": 200}) 203 | await send({"type": "http.response.start", "status": 200}) 204 | 205 | handler = Mangum(app, lifespan="off") 206 | 207 | response = handler(mock_aws_api_gateway_event, {}) 208 | assert response == { 209 | "body": "Internal Server Error", 210 | "headers": {"content-type": "text/plain; charset=utf-8"}, 211 | "multiValueHeaders": {}, 212 | "isBase64Encoded": False, 213 | "statusCode": 500, 214 | } 215 | 216 | 217 | @pytest.mark.parametrize("mock_aws_api_gateway_event", [["GET", b"", None]], indirect=True) 218 | def test_http_binary_gzip_response(mock_aws_api_gateway_event) -> None: 219 | body = json.dumps({"abc": "defg"}) 220 | 221 | async def app(scope, receive, send): 222 | assert scope["type"] == "http" 223 | await send( 224 | { 225 | "type": "http.response.start", 226 | "status": 200, 227 | "headers": [[b"content-type", b"application/json"]], 228 | } 229 | ) 230 | 231 | await send({"type": "http.response.body", "body": body.encode()}) 232 | 233 | handler = Mangum(GZipMiddleware(app, minimum_size=1), lifespan="off") 234 | response = handler(mock_aws_api_gateway_event, {}) 235 | 236 | assert response["isBase64Encoded"] 237 | assert response["headers"] == { 238 | "content-encoding": "gzip", 239 | "content-type": "application/json", 240 | "content-length": "35", 241 | "vary": "Accept-Encoding", 242 | } 243 | assert response["body"] == base64.b64encode(gzip.compress(body.encode())).decode() 244 | 245 | 246 | @pytest.mark.parametrize( 247 | "mock_http_api_event_v2", 248 | [ 249 | (["GET", None, None, ""]), 250 | (["GET", None, {"name": ["me"]}, "name=me"]), 251 | (["GET", None, {"name": ["me", "you"]}, "name=me&name=you"]), 252 | ( 253 | [ 254 | "GET", 255 | None, 256 | {"name": ["me", "you"], "pet": ["dog"]}, 257 | "name=me&name=you&pet=dog", 258 | ] 259 | ), 260 | ], 261 | indirect=["mock_http_api_event_v2"], 262 | ) 263 | def test_set_cookies_v2(mock_http_api_event_v2) -> None: 264 | async def app(scope, receive, send): 265 | assert scope == { 266 | "asgi": {"version": "3.0", "spec_version": "2.0"}, 267 | "aws.context": {}, 268 | "aws.event": { 269 | "version": "2.0", 270 | "routeKey": "$default", 271 | "rawPath": "/my/path", 272 | "rawQueryString": mock_http_api_event_v2["rawQueryString"], 273 | "cookies": ["cookie1", "cookie2"], 274 | "headers": { 275 | "accept-encoding": "gzip,deflate", 276 | "x-forwarded-port": "443", 277 | "x-forwarded-proto": "https", 278 | "host": "test.execute-api.us-west-2.amazonaws.com", 279 | }, 280 | "queryStringParameters": mock_http_api_event_v2["queryStringParameters"], 281 | "requestContext": { 282 | "accountId": "123456789012", 283 | "apiId": "api-id", 284 | "authorizer": { 285 | "jwt": { 286 | "claims": {"claim1": "value1", "claim2": "value2"}, 287 | "scopes": ["scope1", "scope2"], 288 | } 289 | }, 290 | "domainName": "id.execute-api.us-east-1.amazonaws.com", 291 | "domainPrefix": "id", 292 | "http": { 293 | "method": "GET", 294 | "path": "/my/path", 295 | "protocol": "HTTP/1.1", 296 | "sourceIp": "192.168.100.1", 297 | "userAgent": "agent", 298 | }, 299 | "requestId": "id", 300 | "routeKey": "$default", 301 | "stage": "$default", 302 | "time": "12/Mar/2020:19:03:58 +0000", 303 | "timeEpoch": 1_583_348_638_390, 304 | }, 305 | "body": None, 306 | "pathParameters": {"parameter1": "value1"}, 307 | "isBase64Encoded": False, 308 | "stageVariables": { 309 | "stageVariable1": "value1", 310 | "stageVariable2": "value2", 311 | }, 312 | }, 313 | "client": ("192.168.100.1", 0), 314 | "headers": [ 315 | [b"accept-encoding", b"gzip,deflate"], 316 | [b"x-forwarded-port", b"443"], 317 | [b"x-forwarded-proto", b"https"], 318 | [b"host", b"test.execute-api.us-west-2.amazonaws.com"], 319 | [b"cookie", b"cookie1; cookie2"], 320 | ], 321 | "http_version": "1.1", 322 | "method": "GET", 323 | "path": "/my/path", 324 | "query_string": mock_http_api_event_v2["rawQueryString"].encode(), 325 | "raw_path": None, 326 | "root_path": "", 327 | "scheme": "https", 328 | "server": ("test.execute-api.us-west-2.amazonaws.com", 443), 329 | "type": "http", 330 | } 331 | 332 | await send( 333 | { 334 | "type": "http.response.start", 335 | "status": 200, 336 | "headers": [ 337 | [b"content-type", b"text/plain; charset=utf-8"], 338 | [b"set-cookie", b"cookie1=cookie1; Secure"], 339 | [b"set-cookie", b"cookie2=cookie2; Secure"], 340 | [b"multivalue", b"foo"], 341 | [b"multivalue", b"bar"], 342 | ], 343 | } 344 | ) 345 | await send({"type": "http.response.body", "body": b"Hello, world!"}) 346 | 347 | handler = Mangum(app, lifespan="off") 348 | response = handler(mock_http_api_event_v2, {}) 349 | assert response == { 350 | "statusCode": 200, 351 | "isBase64Encoded": False, 352 | "headers": { 353 | "content-type": "text/plain; charset=utf-8", 354 | "multivalue": "foo,bar", 355 | }, 356 | "cookies": ["cookie1=cookie1; Secure", "cookie2=cookie2; Secure"], 357 | "body": "Hello, world!", 358 | } 359 | 360 | 361 | @pytest.mark.parametrize( 362 | "mock_http_api_event_v1", 363 | [ 364 | (["GET", None, None, ""]), 365 | (["GET", None, {"name": ["me"]}, "name=me"]), 366 | (["GET", None, {"name": ["me", "you"]}, "name=me&name=you"]), 367 | ( 368 | [ 369 | "GET", 370 | None, 371 | {"name": ["me", "you"], "pet": ["dog"]}, 372 | "name=me&name=you&pet=dog", 373 | ] 374 | ), 375 | ], 376 | indirect=["mock_http_api_event_v1"], 377 | ) 378 | def test_set_cookies_v1(mock_http_api_event_v1) -> None: 379 | async def app(scope, receive, send): 380 | assert scope == { 381 | "asgi": {"version": "3.0", "spec_version": "2.0"}, 382 | "aws.context": {}, 383 | "aws.event": { 384 | "version": "1.0", 385 | "routeKey": "$default", 386 | "rawPath": "/my/path", 387 | "path": "/my/path", 388 | "httpMethod": "GET", 389 | "rawQueryString": mock_http_api_event_v1["rawQueryString"], 390 | "cookies": ["cookie1", "cookie2"], 391 | "headers": { 392 | "accept-encoding": "gzip,deflate", 393 | "x-forwarded-port": "443", 394 | "x-forwarded-proto": "https", 395 | "host": "test.execute-api.us-west-2.amazonaws.com", 396 | }, 397 | "queryStringParameters": mock_http_api_event_v1["queryStringParameters"], 398 | "multiValueQueryStringParameters": mock_http_api_event_v1["multiValueQueryStringParameters"], 399 | "requestContext": { 400 | "accountId": "123456789012", 401 | "apiId": "api-id", 402 | "authorizer": { 403 | "jwt": { 404 | "claims": {"claim1": "value1", "claim2": "value2"}, 405 | "scopes": ["scope1", "scope2"], 406 | } 407 | }, 408 | "domainName": "id.execute-api.us-east-1.amazonaws.com", 409 | "domainPrefix": "id", 410 | "http": { 411 | "protocol": "HTTP/1.1", 412 | "sourceIp": "192.168.100.1", 413 | "userAgent": "agent", 414 | }, 415 | "requestId": "id", 416 | "routeKey": "$default", 417 | "stage": "$default", 418 | "time": "12/Mar/2020:19:03:58 +0000", 419 | "timeEpoch": 1_583_348_638_390, 420 | }, 421 | "body": None, 422 | "pathParameters": {"parameter1": "value1"}, 423 | "isBase64Encoded": False, 424 | "stageVariables": { 425 | "stageVariable1": "value1", 426 | "stageVariable2": "value2", 427 | }, 428 | }, 429 | "client": (None, 0), 430 | "headers": [ 431 | [b"accept-encoding", b"gzip,deflate"], 432 | [b"x-forwarded-port", b"443"], 433 | [b"x-forwarded-proto", b"https"], 434 | [b"host", b"test.execute-api.us-west-2.amazonaws.com"], 435 | ], 436 | "http_version": "1.1", 437 | "method": "GET", 438 | "path": "/my/path", 439 | "query_string": mock_http_api_event_v1["rawQueryString"].encode(), 440 | "raw_path": None, 441 | "root_path": "", 442 | "scheme": "https", 443 | "server": ("test.execute-api.us-west-2.amazonaws.com", 443), 444 | "type": "http", 445 | } 446 | 447 | await send( 448 | { 449 | "type": "http.response.start", 450 | "status": 200, 451 | "headers": [ 452 | [b"content-type", b"text/plain; charset=utf-8"], 453 | [b"set-cookie", b"cookie1=cookie1; Secure"], 454 | [b"set-cookie", b"cookie2=cookie2; Secure"], 455 | ], 456 | } 457 | ) 458 | await send({"type": "http.response.body", "body": b"Hello, world!"}) 459 | 460 | handler = Mangum(app, lifespan="off") 461 | response = handler(mock_http_api_event_v1, {}) 462 | assert response == { 463 | "statusCode": 200, 464 | "isBase64Encoded": False, 465 | "headers": {"content-type": "text/plain; charset=utf-8"}, 466 | "multiValueHeaders": {"set-cookie": ["cookie1=cookie1; Secure", "cookie2=cookie2; Secure"]}, 467 | "body": "Hello, world!", 468 | } 469 | 470 | 471 | @pytest.mark.parametrize("mock_aws_api_gateway_event", [["GET", "", None]], indirect=True) 472 | def test_http_empty_header(mock_aws_api_gateway_event) -> None: 473 | async def app(scope, receive, send): 474 | assert scope["type"] == "http" 475 | await send( 476 | { 477 | "type": "http.response.start", 478 | "status": 200, 479 | "headers": [[b"content-type", b"text/plain; charset=utf-8"]], 480 | } 481 | ) 482 | await send({"type": "http.response.body", "body": b"Hello, world!"}) 483 | 484 | handler = Mangum(app, lifespan="off") 485 | 486 | mock_aws_api_gateway_event["headers"] = None 487 | 488 | response = handler(mock_aws_api_gateway_event, {}) 489 | assert response == { 490 | "statusCode": 200, 491 | "isBase64Encoded": False, 492 | "headers": {"content-type": "text/plain; charset=utf-8"}, 493 | "multiValueHeaders": {}, 494 | "body": "Hello, world!", 495 | } 496 | 497 | 498 | @pytest.mark.parametrize( 499 | "mock_aws_api_gateway_event,response_headers,expected_headers,expected_multi_value_headers", 500 | [ 501 | [ 502 | ["GET", None, None], 503 | [[b"key1", b"value1"], [b"key2", b"value2"]], 504 | {"key1": "value1", "key2": "value2"}, 505 | {}, 506 | ], 507 | [ 508 | ["GET", None, None], 509 | [[b"key1", b"value1"], [b"key1", b"value2"]], 510 | {}, 511 | {"key1": ["value1", "value2"]}, 512 | ], 513 | [ 514 | ["GET", None, None], 515 | [[b"key1", b"value1"], [b"key1", b"value2"], [b"key1", b"value3"]], 516 | {}, 517 | {"key1": ["value1", "value2", "value3"]}, 518 | ], 519 | [["GET", None, None], [], {}, {}], 520 | ], 521 | indirect=["mock_aws_api_gateway_event"], 522 | ) 523 | def test_http_response_headers( 524 | mock_aws_api_gateway_event, 525 | response_headers, 526 | expected_headers, 527 | expected_multi_value_headers, 528 | ): 529 | async def app(scope, receive, send): 530 | await send( 531 | { 532 | "type": "http.response.start", 533 | "status": 200, 534 | "headers": [[b"content-type", b"text/plain; charset=utf-8"]] + response_headers, 535 | } 536 | ) 537 | await send({"type": "http.response.body", "body": b"Hello, world!"}) 538 | 539 | handler = Mangum(app, lifespan="off") 540 | response = handler(mock_aws_api_gateway_event, {}) 541 | expected = { 542 | "statusCode": 200, 543 | "isBase64Encoded": False, 544 | "headers": {"content-type": "text/plain; charset=utf-8"}, 545 | "multiValueHeaders": {}, 546 | "body": "Hello, world!", 547 | } 548 | if expected_headers: 549 | expected["headers"].update(expected_headers) 550 | if expected_multi_value_headers: 551 | expected["multiValueHeaders"] = expected_multi_value_headers 552 | assert response == expected 553 | 554 | 555 | @pytest.mark.parametrize("mock_aws_api_gateway_event", [["GET", "", None]], indirect=True) 556 | def test_http_binary_br_response(mock_aws_api_gateway_event) -> None: 557 | body = json.dumps({"abc": "defg"}) 558 | 559 | async def app(scope, receive, send): 560 | assert scope["type"] == "http" 561 | await send( 562 | { 563 | "type": "http.response.start", 564 | "status": 200, 565 | "headers": [[b"content-type", b"application/json"]], 566 | } 567 | ) 568 | 569 | await send({"type": "http.response.body", "body": body.encode()}) 570 | 571 | handler = Mangum(BrotliMiddleware(app, minimum_size=1), lifespan="off") 572 | response = handler(mock_aws_api_gateway_event, {}) 573 | 574 | assert response["isBase64Encoded"] 575 | assert response["headers"] == { 576 | "content-encoding": "br", 577 | "content-type": "application/json", 578 | "content-length": "19", 579 | "vary": "Accept-Encoding", 580 | } 581 | assert response["body"] == base64.b64encode(brotli.compress(body.encode())).decode() 582 | 583 | 584 | @pytest.mark.parametrize("mock_aws_api_gateway_event", [["GET", b"", None]], indirect=True) 585 | def test_http_logging(mock_aws_api_gateway_event, caplog: pytest.LogCaptureFixture) -> None: 586 | async def app(scope, receive, send): 587 | assert scope["type"] == "http" 588 | await send( 589 | { 590 | "type": "http.response.start", 591 | "status": 200, 592 | "headers": [[b"content-type", b"text/plain; charset=utf-8"]], 593 | } 594 | ) 595 | 596 | await send({"type": "http.response.body", "body": b"Hello, world!"}) 597 | 598 | handler = Mangum(app, lifespan="off") 599 | response = handler(mock_aws_api_gateway_event, {}) 600 | 601 | assert response == { 602 | "statusCode": 200, 603 | "isBase64Encoded": False, 604 | "headers": {"content-type": "text/plain; charset=utf-8"}, 605 | "multiValueHeaders": {}, 606 | "body": "Hello, world!", 607 | } 608 | 609 | assert "GET /test/hello 200" in caplog.text 610 | -------------------------------------------------------------------------------- /tests/test_lifespan.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import pytest 4 | from quart import Quart 5 | from starlette.applications import Starlette 6 | from starlette.responses import PlainTextResponse 7 | from typing_extensions import Literal 8 | 9 | from mangum import Mangum 10 | from mangum.exceptions import LifespanFailure 11 | from mangum.types import Receive, Scope, Send 12 | 13 | 14 | @pytest.mark.parametrize( 15 | "mock_aws_api_gateway_event,lifespan", 16 | [ 17 | (["GET", None, None], "auto"), 18 | (["GET", None, None], "on"), 19 | (["GET", None, None], "off"), 20 | ], 21 | indirect=["mock_aws_api_gateway_event"], 22 | ) 23 | def test_lifespan(mock_aws_api_gateway_event, lifespan) -> None: 24 | """ 25 | Test each lifespan option using an application that supports lifespan messages. 26 | 27 | * "auto" (default): 28 | Application support for lifespan will be inferred. 29 | 30 | Any error that occurs during startup will be logged and the ASGI application 31 | cycle will continue unless a `lifespan.startup.failed` event is sent. 32 | 33 | * "on": 34 | Application support for lifespan is explicit. 35 | 36 | Any error that occurs during startup will be raised and a 500 response will 37 | be returned. 38 | 39 | * "off": 40 | Application support for lifespan should be ignored. 41 | 42 | The application will not enter the lifespan cycle context. 43 | """ 44 | startup_complete = False 45 | shutdown_complete = False 46 | 47 | async def app(scope, receive, send): 48 | nonlocal startup_complete, shutdown_complete 49 | 50 | if scope["type"] == "lifespan": 51 | while True: 52 | message = await receive() 53 | if message["type"] == "lifespan.startup": 54 | await send({"type": "lifespan.startup.complete"}) 55 | startup_complete = True 56 | elif message["type"] == "lifespan.shutdown": 57 | await send({"type": "lifespan.shutdown.complete"}) 58 | shutdown_complete = True 59 | return 60 | 61 | if scope["type"] == "http": 62 | await send( 63 | { 64 | "type": "http.response.start", 65 | "status": 200, 66 | "headers": [[b"content-type", b"text/plain; charset=utf-8"]], 67 | } 68 | ) 69 | await send({"type": "http.response.body", "body": b"Hello, world!"}) 70 | 71 | handler = Mangum(app, lifespan=lifespan) 72 | response = handler(mock_aws_api_gateway_event, {}) 73 | expected = lifespan in ("on", "auto") 74 | 75 | assert startup_complete == expected 76 | assert shutdown_complete == expected 77 | assert response == { 78 | "statusCode": 200, 79 | "isBase64Encoded": False, 80 | "headers": {"content-type": "text/plain; charset=utf-8"}, 81 | "multiValueHeaders": {}, 82 | "body": "Hello, world!", 83 | } 84 | 85 | 86 | @pytest.mark.parametrize( 87 | "mock_aws_api_gateway_event,lifespan", 88 | [ 89 | (["GET", None, None], "auto"), 90 | (["GET", None, None], "on"), 91 | (["GET", None, None], "off"), 92 | ], 93 | indirect=["mock_aws_api_gateway_event"], 94 | ) 95 | def test_lifespan_unsupported(mock_aws_api_gateway_event, lifespan) -> None: 96 | """ 97 | Test each lifespan option with an application that does not support lifespan events. 98 | """ 99 | 100 | async def app(scope, receive, send): 101 | await send( 102 | { 103 | "type": "http.response.start", 104 | "status": 200, 105 | "headers": [[b"content-type", b"text/plain; charset=utf-8"]], 106 | } 107 | ) 108 | await send({"type": "http.response.body", "body": b"Hello, world!"}) 109 | 110 | handler = Mangum(app, lifespan=lifespan) 111 | response = handler(mock_aws_api_gateway_event, {}) 112 | 113 | assert response == { 114 | "statusCode": 200, 115 | "isBase64Encoded": False, 116 | "headers": {"content-type": "text/plain; charset=utf-8"}, 117 | "multiValueHeaders": {}, 118 | "body": "Hello, world!", 119 | } 120 | 121 | 122 | @pytest.mark.parametrize( 123 | "mock_aws_api_gateway_event,lifespan", 124 | [(["GET", None, None], "auto"), (["GET", None, None], "on")], 125 | indirect=["mock_aws_api_gateway_event"], 126 | ) 127 | def test_lifespan_error(mock_aws_api_gateway_event, lifespan, caplog) -> None: 128 | caplog.set_level(logging.ERROR) 129 | 130 | async def app(scope, receive, send): 131 | if scope["type"] == "lifespan": 132 | while True: 133 | message = await receive() 134 | if message["type"] == "lifespan.startup": 135 | raise Exception("error") 136 | else: 137 | await send( 138 | { 139 | "type": "http.response.start", 140 | "status": 200, 141 | "headers": [[b"content-type", b"text/plain; charset=utf-8"]], 142 | } 143 | ) 144 | await send({"type": "http.response.body", "body": b"Hello, world!"}) 145 | 146 | handler = Mangum(app, lifespan=lifespan) 147 | response = handler(mock_aws_api_gateway_event, {}) 148 | 149 | assert "Exception in 'lifespan' protocol." in caplog.text 150 | assert response == { 151 | "statusCode": 200, 152 | "isBase64Encoded": False, 153 | "headers": {"content-type": "text/plain; charset=utf-8"}, 154 | "multiValueHeaders": {}, 155 | "body": "Hello, world!", 156 | } 157 | 158 | 159 | @pytest.mark.parametrize( 160 | "mock_aws_api_gateway_event,lifespan", 161 | [(["GET", None, None], "auto"), (["GET", None, None], "on")], 162 | indirect=["mock_aws_api_gateway_event"], 163 | ) 164 | def test_lifespan_unexpected_message(mock_aws_api_gateway_event, lifespan) -> None: 165 | async def app(scope, receive, send): 166 | if scope["type"] == "lifespan": 167 | while True: 168 | message = await receive() 169 | if message["type"] == "lifespan.startup": 170 | await send( 171 | { 172 | "type": "http.response.start", 173 | "status": 200, 174 | "headers": [[b"content-type", b"text/plain; charset=utf-8"]], 175 | } 176 | ) 177 | 178 | handler = Mangum(app, lifespan=lifespan) 179 | with pytest.raises(LifespanFailure): 180 | handler(mock_aws_api_gateway_event, {}) 181 | 182 | 183 | @pytest.mark.parametrize( 184 | "mock_aws_api_gateway_event,lifespan,failure_type", 185 | [ 186 | (["GET", None, None], "auto", "startup"), 187 | (["GET", None, None], "on", "startup"), 188 | (["GET", None, None], "auto", "shutdown"), 189 | (["GET", None, None], "on", "shutdown"), 190 | ], 191 | indirect=["mock_aws_api_gateway_event"], 192 | ) 193 | def test_lifespan_failure(mock_aws_api_gateway_event, lifespan, failure_type) -> None: 194 | async def app(scope, receive, send): 195 | if scope["type"] == "lifespan": 196 | while True: 197 | message = await receive() 198 | if message["type"] == "lifespan.startup": 199 | if failure_type == "startup": 200 | await send({"type": "lifespan.startup.failed", "message": "Failed."}) 201 | else: 202 | await send({"type": "lifespan.startup.complete"}) 203 | if message["type"] == "lifespan.shutdown": 204 | if failure_type == "shutdown": 205 | await send({"type": "lifespan.shutdown.failed", "message": "Failed."}) 206 | await send({"type": "lifespan.shutdown.complete"}) 207 | 208 | handler = Mangum(app, lifespan=lifespan) 209 | 210 | with pytest.raises(LifespanFailure): 211 | handler(mock_aws_api_gateway_event, {}) 212 | 213 | 214 | @pytest.mark.parametrize( 215 | "mock_aws_api_gateway_event,lifespan", 216 | [(["GET", None, None], "auto"), (["GET", None, None], "on")], 217 | indirect=["mock_aws_api_gateway_event"], 218 | ) 219 | def test_lifespan_state(mock_aws_api_gateway_event, lifespan: Literal["on", "auto"]) -> None: 220 | startup_complete = False 221 | shutdown_complete = False 222 | 223 | async def app(scope: Scope, receive: Receive, send: Send): 224 | nonlocal startup_complete, shutdown_complete 225 | 226 | if scope["type"] == "lifespan": 227 | while True: 228 | message = await receive() 229 | if message["type"] == "lifespan.startup": 230 | scope["state"].update({"test_key": b"Hello, world!"}) 231 | await send({"type": "lifespan.startup.complete"}) 232 | startup_complete = True 233 | elif message["type"] == "lifespan.shutdown": 234 | await send({"type": "lifespan.shutdown.complete"}) 235 | shutdown_complete = True 236 | return 237 | 238 | if scope["type"] == "http": 239 | await send( 240 | { 241 | "type": "http.response.start", 242 | "status": 200, 243 | "headers": [[b"content-type", b"text/plain; charset=utf-8"]], 244 | } 245 | ) 246 | await send({"type": "http.response.body", "body": scope["state"]["test_key"]}) 247 | 248 | handler = Mangum(app, lifespan=lifespan) 249 | response = handler(mock_aws_api_gateway_event, {}) 250 | 251 | assert startup_complete 252 | assert shutdown_complete 253 | assert response == { 254 | "statusCode": 200, 255 | "isBase64Encoded": False, 256 | "headers": {"content-type": "text/plain; charset=utf-8"}, 257 | "multiValueHeaders": {}, 258 | "body": "Hello, world!", 259 | } 260 | 261 | 262 | @pytest.mark.parametrize("mock_aws_api_gateway_event", [["GET", None, None]], indirect=True) 263 | def test_starlette_lifespan(mock_aws_api_gateway_event) -> None: 264 | startup_complete = False 265 | shutdown_complete = False 266 | 267 | path = mock_aws_api_gateway_event["path"] 268 | app = Starlette() 269 | 270 | @app.on_event("startup") 271 | async def on_startup(): 272 | nonlocal startup_complete 273 | startup_complete = True 274 | 275 | @app.on_event("shutdown") 276 | async def on_shutdown(): 277 | nonlocal shutdown_complete 278 | shutdown_complete = True 279 | 280 | @app.route(path) 281 | def homepage(request): 282 | return PlainTextResponse("Hello, world!") 283 | 284 | assert not startup_complete 285 | assert not shutdown_complete 286 | 287 | handler = Mangum(app) 288 | mock_aws_api_gateway_event["body"] = None 289 | 290 | response = handler(mock_aws_api_gateway_event, {}) 291 | assert startup_complete 292 | assert shutdown_complete 293 | assert response == { 294 | "statusCode": 200, 295 | "isBase64Encoded": False, 296 | "headers": { 297 | "content-length": "13", 298 | "content-type": "text/plain; charset=utf-8", 299 | }, 300 | "multiValueHeaders": {}, 301 | "body": "Hello, world!", 302 | } 303 | 304 | 305 | @pytest.mark.parametrize("mock_aws_api_gateway_event", [["GET", None, None]], indirect=True) 306 | def test_quart_lifespan(mock_aws_api_gateway_event) -> None: 307 | startup_complete = False 308 | shutdown_complete = False 309 | path = mock_aws_api_gateway_event["path"] 310 | app = Quart(__name__) 311 | 312 | @app.before_serving 313 | async def on_startup(): 314 | nonlocal startup_complete 315 | startup_complete = True 316 | 317 | @app.after_serving 318 | async def on_shutdown(): 319 | nonlocal shutdown_complete 320 | shutdown_complete = True 321 | 322 | @app.route(path) 323 | async def hello(): 324 | return "hello world!" 325 | 326 | assert not startup_complete 327 | assert not shutdown_complete 328 | 329 | handler = Mangum(app) 330 | response = handler(mock_aws_api_gateway_event, {}) 331 | 332 | assert startup_complete 333 | assert shutdown_complete 334 | assert response == { 335 | "statusCode": 200, 336 | "isBase64Encoded": False, 337 | "headers": {"content-length": "12", "content-type": "text/html; charset=utf-8"}, 338 | "multiValueHeaders": {}, 339 | "body": "hello world!", 340 | } 341 | --------------------------------------------------------------------------------