├── .coveragerc ├── .envrc ├── .eslintrc.js ├── .flake8 ├── .github └── workflows │ └── tests.yaml ├── .gitignore ├── .tool-versions ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── assets ├── command.svg ├── header.svg ├── hello-world.svg └── serve.svg ├── devenv.lock ├── devenv.nix ├── devenv.yaml ├── index.js ├── index.test.js ├── package-lock.json ├── package.json ├── requirements.py ├── requirements.txt ├── requirements_test.py ├── serve.py ├── serve_test.py ├── serverless_wsgi.py ├── setup.cfg ├── setup.py ├── wsgi_handler.py └── wsgi_handler_test.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = setup.py 3 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | source_url "https://raw.githubusercontent.com/cachix/devenv/d1f7b48e35e6dee421cfd0f51481d17f77586997/direnvrc" "sha256-YBzqskFZxmNb3kYVoKD9ZixoPXJh1C9ZvTLGFRkauZ0=" 2 | 3 | use devenv -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | es6: true, 4 | node: true, 5 | mocha: true, 6 | }, 7 | extends: "eslint:recommended", 8 | parserOptions: { 9 | sourceType: "module", 10 | }, 11 | rules: { 12 | indent: ["error", 2], 13 | "linebreak-style": ["error", "unix"], 14 | quotes: ["warn", "double", { avoidEscape: true }], 15 | semi: ["error", "always"], 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E203,E501,W503 3 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: [push, pull_request] 3 | jobs: 4 | tests: 5 | name: ${{ matrix.name }} 6 | runs-on: ${{ matrix.os }} 7 | strategy: 8 | fail-fast: false 9 | matrix: 10 | include: 11 | - { name: "3.13", python: "3.13", os: ubuntu-latest } 12 | - { name: "3.9", python: "3.9", os: ubuntu-latest } 13 | - { name: "3.8", python: "3.8", os: ubuntu-latest } 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: "14" 20 | - run: npm install 21 | 22 | - uses: actions/setup-python@v4 23 | with: 24 | python-version: ${{ matrix.python }} 25 | - name: update pip 26 | run: | 27 | pip install -U wheel 28 | pip install -U setuptools 29 | python -m pip install -U pip 30 | - name: get pip cache dir 31 | id: pip-cache 32 | run: echo "::set-output name=dir::$(pip cache dir)" 33 | - name: cache pip 34 | uses: actions/cache@v4 35 | with: 36 | path: ${{ steps.pip-cache.outputs.dir }} 37 | key: pip|${{ runner.os }}|${{ matrix.python }}|${{ hashFiles('setup.py') }}|${{ hashFiles('requirements.txt') }} 38 | - run: pip install pytest pytest-cov flake8 virtualenv urllib3[secure] 39 | - run: pip install -r requirements.txt 40 | 41 | - run: npm test 42 | - run: npm run lint 43 | - run: npm run pytest 44 | - run: npm run pylint 45 | 46 | - uses: codecov/codecov-action@v4 47 | with: 48 | token: "89d22de7-bfaf-43a0-81da-33cc733fd294" 49 | fail_ci_if_error: true 50 | verbose: true 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | dist 11 | 12 | # Directory for instrumented libs generated by jscoverage/JSCover 13 | lib-cov 14 | 15 | # Coverage directory used by tools like istanbul 16 | coverage 17 | .coverage 18 | htmlcov 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directory 30 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 31 | node_modules 32 | 33 | #IDE Stuff 34 | **/.idea 35 | 36 | #OS STUFF 37 | .DS_Store 38 | .tmp 39 | 40 | #SERVERLESS STUFF 41 | admin.env 42 | .env 43 | 44 | # Python 45 | *.pyc 46 | __pycache__ 47 | .cache 48 | 49 | env/ 50 | build/ 51 | *.egg-info/ 52 | 53 | # Devenv 54 | .devenv* 55 | devenv.local.nix 56 | 57 | # direnv 58 | .direnv 59 | 60 | # pre-commit 61 | .pre-commit-config.yaml 62 | 63 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 16.13.1 2 | python 3.10.0 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 3.0.5 2 | 3 | ## Security 4 | 5 | - Remove hasbin dependency 6 | 7 | ## Features 8 | 9 | - Add ability to provide function to execute when calling sls wsgi command 10 | 11 | _ayaan-qadri_ 12 | 13 | # 3.0.4 14 | 15 | ## Bugs 16 | 17 | - Fix lambda response processing 18 | 19 | _Eric Petway_ 20 | 21 | # 3.0.3 22 | 23 | ## Features 24 | 25 | - As of Werkzeug 3.0.0, url_encode is no longer available, use the urllib counterparts 26 | 27 | _Ryan Whittaker_ 28 | 29 | # 3.0.2 30 | 31 | ## Features 32 | 33 | - Support base path stripping for v2 events 34 | - Default to https when protocol not specified in event 35 | - Add blacklist entries 36 | 37 | _arsoni20_ 38 | 39 | ## Bugs 40 | 41 | - Fix handling of v1 event payload emitted by Serverless Offline plugin 42 | - Handle event with None body emitted by Serverless Offline plugin 43 | - Fixes for Scaleway 44 | 45 | _Andrej Shadura_ 46 | 47 | # 3.0.1 48 | 49 | ## Bugs 50 | 51 | - Fix console output on commands/manage 52 | 53 | _Justin Lyons_ 54 | 55 | # 3.0.0 56 | 57 | ## Features 58 | 59 | - Update serverless integration for v3 compatibility (breaks integration with serverless < 2.32.0) (#193) 60 | 61 | _Mariusz Nowak_ 62 | 63 | - Add options for specifying SSL cert location when serving locally (#195) 64 | 65 | _Nathaniel J. Padgett_ 66 | 67 | - Support wsgi handler placed in a subfolder (#198) 68 | 69 | _Felipe Passos_ 70 | 71 | # 2.0.2 72 | 73 | ## Bugs 74 | 75 | - Compatibility upgrade for serverless 2.32 (#189) 76 | 77 | _Justin Lyons_ 78 | 79 | # 2.0.1 80 | 81 | ## Bugs 82 | 83 | - Lambda integration handler invoked for API Gateway proxy events, caused by #185 (#188) 84 | 85 | # 2.0.0 86 | 87 | ## Features 88 | 89 | - Drops Python 2 support and require Werkzeug 2 or later 90 | - Remove deprecated API_GATEWAY_AUTHORIZER, event and context variables from WSGI environment. (Use serverless.authorizer, serverless.event and serverless.context instead) 91 | 92 | # 1.7.8 93 | 94 | ## Bugs 95 | 96 | - Pin Werkzeug version (#178) 97 | 98 | _Adam Chelminski_ 99 | 100 | # 1.7.7 101 | 102 | ## Features 103 | 104 | - Add handler for lambda integration (#167) 105 | 106 | _Jan Varho_ 107 | 108 | ## Bugs 109 | 110 | - Fix serverless deprecation warnings (#174) 111 | 112 | _Jan Varho_ 113 | 114 | # 1.7.6 115 | 116 | ## Features 117 | 118 | - Support for the new HTTP API lambda proxy response payload v2.0 (#149) 119 | 120 | _Ronald Tscherepanow_ 121 | 122 | ## Bugs 123 | 124 | - Fix eventContext for KONG gateway (#147) 125 | 126 | _Grant Johnson_ 127 | 128 | - Fix the ALB query parameter handling (#146) 129 | 130 | _Hsiao-Ting Yu_ 131 | 132 | # 1.7.5 133 | 134 | ## Bugs 135 | 136 | - Fix integration with virtualenv for latest version (20.x) 137 | - Fix wrong encoding of error messages during packaging (#122, #139) 138 | 139 | _Jan Varho_ 140 | 141 | # 1.7.4 142 | 143 | ## Bugs 144 | 145 | - Return error exit code when `exec`, `command`, `manage` or `flask` commands fail (#114) 146 | - Display output from failing `command` invocations instead of throwing exception (#107) 147 | 148 | # 1.7.3 149 | 150 | ## Features 151 | 152 | - Add `--ssl` flag to `sls wsgi serve` (#103) 153 | - Add log message when skipping handler on warmup events (#95) 154 | - Add options for disabling threading and setting number of processes when invoking `sls wsgi serve` (#100) 155 | 156 | _Bryan Worrell_ 157 | 158 | - Allow use of CloudFront with a pre-set path (#101) 159 | 160 | _Paul Bowsher_ 161 | 162 | ## Bugs 163 | 164 | - Properly decode event `path` into environ `PATH_INFO` (#93) 165 | - Fix local serving when package `individually: true` and function `module` are provided (#98) 166 | - Fix Flask CLI invocation of built-in commands (#99) 167 | 168 | _Mischa Spiegelmock_ 169 | 170 | # 1.7.2 171 | 172 | ## Features 173 | 174 | - Support multi-value query string parameters (#87) 175 | 176 | _Jan Varho_ 177 | 178 | - Support multi-value headers in request and response 179 | - Add `sls wsgi flask` and `sls wsgi flask local` commands (#86) 180 | 181 | # 1.7.1 182 | 183 | ## Features 184 | 185 | - Add local versions of manage, command and exec commands (#79) 186 | - Support serverless-python-requirements packaging with `individually` and `module` configuration (#85) 187 | 188 | # 1.7.0 189 | 190 | ## Features 191 | 192 | - Rename `.wsgi_app` to `.serverless-wsgi` (to follow convention from `serverless-rack`) 193 | - Check for werkzeug presence in bundle or issue warning (#80) 194 | 195 | ## Bugs 196 | 197 | - The `wsgi.handler` has been renamed to `wsgi_handler.handler` due to a naming 198 | conflict with an internal AWS wsgi module. A warning and workaround is issued in order 199 | to prevent breaking existing configuration files (#84) 200 | 201 | # 1.6.1 202 | 203 | ## Features 204 | 205 | - Use proper namespacing for custom WSGI environment variables: `serverless.authorizer`, `serverless.event` and `serverless.context`. 206 | Note: `API_GATEWAY_AUTHORIZER`, `event` and `context` will be deprecated later. 207 | - Permute header casings for multiple values of any header, not just `Set-Cookie` 208 | 209 | # 1.6.0 210 | 211 | ## Features 212 | 213 | - Add `exec`, `command` and `manage` CLI commands for invoking scripts remotely (#75) 214 | - Detect presence of `serverless-python-requirements` and disable `packRequirements` automatically 215 | - Add `pipArgs` configuration option for passing additional arguments to pip (#76) 216 | - Add support for ALB requests (#77) 217 | 218 | _Alan Trope_ 219 | 220 | - Improve log output for errors at import-time 221 | 222 | _Jackal_ 223 | 224 | # 1.5.3 225 | 226 | ## Features 227 | 228 | - Add `sls wsgi install` command to install WSGI handler and requirements for local use 229 | - Support `sls invoke local` (serverless/serverless#5475) 230 | 231 | # 1.5.2 232 | 233 | ## Features 234 | 235 | - Add `image/svg+xml` to default text mime types (#74) 236 | 237 | ## Bugs 238 | 239 | - Add missing `werkzeug` requirement to `setup.py` (#73) 240 | 241 | # 1.5.1 242 | 243 | ## Bugs 244 | 245 | - Fix import error when using unzip_requirements from serverless-python-requirements (#72) 246 | 247 | _Justin Plock_ 248 | 249 | # 1.5.0 250 | 251 | ## Features 252 | 253 | - Allow adding additional text mime-types (#68) 254 | - Improve detection of available Python executable and associated error messages (#66) 255 | - Start multithreaded server when running `sls wsgi serve` (#69) 256 | - Publish Python package to PyPI (#63) 257 | 258 | ## Internal 259 | 260 | - Change `.wsgi_app` to contain JSON serialized configuration object 261 | 262 | # 1.4.9 263 | 264 | ## Features 265 | 266 | - Add compatibility with serverless-offline (#61) 267 | 268 | _Matthew Hardwick_ 269 | 270 | ## Bugs 271 | 272 | - Set `IS_OFFLINE` before importing application when running under `sls wsgi serve` (#65) 273 | 274 | # 1.4.8 275 | 276 | ## Bugs 277 | 278 | - Set correct SCRIPT_NAME in `amazonaws.com.*` AWS regions 279 | 280 | _Winton Wang_ 281 | 282 | # 1.4.7 283 | 284 | ## Features 285 | 286 | - Gracefully handle scheduled events and invocations from serverless-plugin-warmup (#54) 287 | 288 | _Chao Xie_ 289 | 290 | - Enable zip dependencies when using the serverless-python-requirements plugin (#56) 291 | 292 | _Eric Magalhães_ 293 | 294 | # Bugs 295 | 296 | - Skip setting CloudFormation-interpreted environment variables during local serving (#53) 297 | - Include `application/javascript` as a plain text MIME type (#55) 298 | 299 | # 1.4.6 300 | 301 | ## Bugs 302 | 303 | - Skip WSGI encoding dance for request body to avoid garbling UTF-8 characters 304 | 305 | _Shintaro Tanaka_ 306 | 307 | # 1.4.5 308 | 309 | ## Features 310 | 311 | - Ignore `*.dist-info` and `*.pyc` when packaging requirements 312 | - Remove `.requirements` prior to packaging to avoid deploying packages 313 | that are no longer required 314 | 315 | # 1.4.4 316 | 317 | ## Features 318 | 319 | - Make binding host configurable when invoking `sls wsgi serve` 320 | 321 | _Eric Magalhães_ 322 | 323 | - Add `application/vnd.api+json` to list of non-binary MIME types 324 | 325 | _Marshal Newrock_ 326 | 327 | # 1.4.3 328 | 329 | ## Bugs 330 | 331 | - Fix double conversion issue for binary payloads 332 | 333 | _Alex DeBrie_ 334 | 335 | # 1.4.2 336 | 337 | ## Bugs 338 | 339 | - Fix calculation of content length for binary payloads on Python 3 340 | - WSGI error stream was output to stdout instead of stderr 341 | 342 | # 1.4.1 343 | 344 | ## Features 345 | 346 | - Add IS_OFFLINE environment variable to serve (#42). 347 | 348 | _Alex DeBrie_ 349 | 350 | - Handle binary request payloads and compressed responses (#41). 351 | 352 | _Malcolm Jones_ 353 | 354 | - Provide access to raw `event` through request environment (#37). 355 | 356 | ## Bugs 357 | 358 | - Fixed issue where CONTENT_LENGTH was computed differently than the wsgi.input (#40). 359 | 360 | _Phil Hachey_ 361 | 362 | - Fix deprecation warnings for the before:deploy:createDeploymentArtifacts and after:deploy:createDeploymentArtifacts hooks (#43). 363 | 364 | _Malcolm Jones_ 365 | 366 | - Blacklist `__pycache__` from requirements packaging in order to avoid conflicts (#35). 367 | - Fix insecure usage of X-Forwarded-For (#36). 368 | - Explicitly set virtualenv interpreter when packaging requirements (#34). 369 | 370 | # 1.4.0 371 | 372 | ## Features 373 | 374 | - Package requirements into service root directory in order to avoid munging 375 | sys.path to load requirements (#30). 376 | - Package requirements when deploying individual non-WSGI functions (#30). 377 | - Added `pythonBin` option to set python executable, defaulting to current runtime version (#29). 378 | 379 | # 1.3.1 380 | 381 | ## Features 382 | 383 | - Add configuration for handling base path mappings (API_GATEWAY_BASE_PATH) 384 | 385 | _Alex DeBrie_ 386 | 387 | ## Bugs 388 | 389 | - Only add .requirements folder to includes when packing enabled 390 | 391 | _Darcy Rayner_ 392 | 393 | # 1.3.0 394 | 395 | ## Features 396 | 397 | - Load subdirectory packages by adding the subdirectory to the search path (i.e. setting the wsgi handler to something like `dir/api.app.handler`). 398 | 399 | Previously, the subdirectory was expected to be a package (i.e. containing `__init__.py`) 400 | 401 | ## Bugs 402 | 403 | - Skip removing `.requirements` if `packRequirements: false` 404 | 405 | _Alex DeBrie_ 406 | 407 | - Supply wsgi.input as BytesIO on Python 3 408 | 409 | _Brett Higgins_ 410 | 411 | # 1.2.2 412 | 413 | ## Features 414 | 415 | - Add default package includes for `.wsgi_app` and `.requirements` 416 | 417 | ## Bugs 418 | 419 | - Fix requirement packaging on Mac OS with Python 3.6 (Anaconda) 420 | 421 | _Vitaly Davydov_ 422 | 423 | # 1.2.1 424 | 425 | ## Features 426 | 427 | - Support base64 encoding of binary responses automatically based on MIME type 428 | 429 | _Andre de Cavaignac_ 430 | 431 | ## Bugs 432 | 433 | - Properly handle Python 3 bytestring response 434 | 435 | _Andre de Cavaignac_ 436 | 437 | # 1.2.0 438 | 439 | ## Features 440 | 441 | - Python 3 support 442 | 443 | # 1.1.1 444 | 445 | ## Features 446 | 447 | - Pass Lambda context in the `context` property of the WSGI environment. 448 | 449 | _Lucas Costa_ 450 | 451 | # 1.1.0 452 | 453 | ## Features 454 | 455 | - Support for multiple Set-Cookie headers (#11). _Thanks to Ben Bangert for creating an issue and providing an implementation._ 456 | 457 | - Forward API Gateway authorizer information as API_GATEWAY_AUTHORIZER in the WSGI request environment (#7) 458 | 459 | _Thanks to Greg Zapp for reporting_ 460 | 461 | # 1.0.4 462 | 463 | ## Features 464 | 465 | - Optional requirement packaging: Skips requirement packaging if `custom.wsgi.packRequirements` is set to false 466 | 467 | _Lucas Costa_ 468 | 469 | - Adds support for packaging requirements when wsgi app is in a subdirectory (i.e. setting the wsgi handler to something like `dir/app.handler`). 470 | 471 | _Lucas Costa_ 472 | 473 | - Package WSGI handler and requirements on single-function deployment 474 | 475 | _Lucas Costa_ 476 | 477 | # 1.0.3 478 | 479 | ## Features 480 | 481 | - Adds support for packaging handlers inside directories (i.e. setting the wsgi handler to something like `dir/app.handler`). 482 | 483 | _Lucas Costa_ 484 | 485 | # 1.0.2 486 | 487 | ## Features 488 | 489 | - Added unit tests. 490 | 491 | ## Bugs 492 | 493 | - Internal requirements file was not included when user requirements file was present. 494 | 495 | # 1.0.1 496 | 497 | ## Features 498 | 499 | - Enable using the requirements packaging functionality alone, without the WSGI handler. This is enabled by omitting the `custom.wsgi.app` setting from `serverless.yml`. 500 | - Load provider and function environment variables when serving WSGI app locally. 501 | 502 | ## Bugs 503 | 504 | - If no `requirements.txt` file is present and the WSGI handler is enabled, make sure to package werkzeug. 505 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Logan Raarup 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 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGELOG.md 2 | include LICENSE 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | [![npm package](https://nodei.co/npm/serverless-wsgi.svg?downloads=true&downloadRank=true&stars=true)](https://nodei.co/npm/serverless-wsgi/) 6 | 7 | [![serverless](http://public.serverless.com/badges/v3.svg)](http://www.serverless.com) 8 | [![Tests](https://github.com/logandk/serverless-wsgi/actions/workflows/tests.yaml/badge.svg)](https://github.com/logandk/serverless-wsgi/actions/workflows/tests.yaml) 9 | [![Coverage Status](https://codecov.io/gh/logandk/serverless-wsgi/branch/master/graph/badge.svg)](https://codecov.io/gh/logandk/serverless-wsgi) 10 | 11 | A Serverless Framework plugin to build your deploy Python WSGI applications using Serverless. Compatible 12 | WSGI application frameworks include Flask, Django and Pyramid - for a complete list, see: 13 | http://wsgi.readthedocs.io/en/latest/frameworks.html. 14 | 15 | ### Features 16 | 17 | - Transparently converts API Gateway and ALB requests to and from standard WSGI requests 18 | - Supports anything you'd expect from WSGI such as redirects, cookies, file uploads etc. 19 | - Automatically downloads Python packages that you specify in `requirements.txt` and deploys them along with your application 20 | - Convenient `wsgi serve` command for serving your application locally during development 21 | - Includes CLI commands for remote execution of Python code (`wsgi exec`), shell commands (`wsgi command`), Flask CLI commands (`wsgi flask`) and Django management commands (`wsgi manage`) 22 | - Supports both APIGatewayV1 and APIGatewayV2 payloads 23 | 24 | ## Install 25 | 26 | ``` 27 | sls plugin install -n serverless-wsgi 28 | ``` 29 | 30 | This will automatically add the plugin to `package.json` and the plugins section of `serverless.yml`. 31 | 32 | ## Flask configuration example 33 | 34 |

35 | 36 |

37 | 38 | This example assumes that you have intialized your application as `app` inside `api.py`. 39 | 40 | ``` 41 | project 42 | ├── api.py 43 | ├── requirements.txt 44 | └── serverless.yml 45 | ``` 46 | 47 | ### api.py 48 | 49 | A regular Flask application. 50 | 51 | ```python 52 | from flask import Flask 53 | app = Flask(__name__) 54 | 55 | 56 | @app.route("/cats") 57 | def cats(): 58 | return "Cats" 59 | 60 | 61 | @app.route("/dogs/") 62 | def dog(id): 63 | return "Dog" 64 | ``` 65 | 66 | ### serverless.yml 67 | 68 | Load the plugin and set the `custom.wsgi.app` configuration in `serverless.yml` to the 69 | module path of your Flask application. 70 | 71 | All functions that will use WSGI need to have `wsgi_handler.handler` set as the Lambda handler and 72 | use the default `lambda-proxy` integration for API Gateway. This configuration example treats 73 | API Gateway as a transparent proxy, passing all requests directly to your Flask application, 74 | and letting the application handle errors, 404s etc. 75 | 76 | _Note: The WSGI handler was called `wsgi.handler` earlier, but was renamed to `wsgi_handler.handler` 77 | in `1.7.0`. The old name is still supported but using it will cause a deprecation warning._ 78 | 79 | ```yaml 80 | service: example 81 | 82 | provider: 83 | name: aws 84 | runtime: python3.6 85 | 86 | plugins: 87 | - serverless-wsgi 88 | 89 | functions: 90 | api: 91 | handler: wsgi_handler.handler 92 | events: 93 | - http: ANY / 94 | - http: ANY /{proxy+} 95 | 96 | custom: 97 | wsgi: 98 | app: api.app 99 | ``` 100 | 101 | ### requirements.txt 102 | 103 | Add Flask to the application bundle. 104 | 105 | ``` 106 | Flask==1.0.2 107 | ``` 108 | 109 | ## Deployment 110 | 111 | Simply run the serverless deploy command as usual: 112 | 113 | ``` 114 | $ sls deploy 115 | Serverless: Using Python specified in "runtime": python3.6 116 | Serverless: Packaging Python WSGI handler... 117 | Serverless: Packaging required Python packages... 118 | Serverless: Linking required Python packages... 119 | Serverless: Packaging service... 120 | Serverless: Excluding development dependencies... 121 | Serverless: Unlinking required Python packages... 122 | Serverless: Uploading CloudFormation file to S3... 123 | Serverless: Uploading artifacts... 124 | Serverless: Uploading service .zip file to S3 (864.57 KB)... 125 | Serverless: Validating template... 126 | Serverless: Updating Stack... 127 | Serverless: Checking Stack update progress... 128 | .............. 129 | Serverless: Stack update finished... 130 | ``` 131 | 132 | ## Other frameworks 133 | 134 | Set `custom.wsgi.app` in `serverless.yml` according to your WSGI callable: 135 | 136 | - For Pyramid, use [make_wsgi_app](http://docs.pylonsproject.org/projects/pyramid/en/latest/api/config.html#pyramid.config.Configurator.make_wsgi_app) to intialize the callable 137 | - Django is configured for WSGI by default, set the callable to `.wsgi.application`. See https://docs.djangoproject.com/en/1.10/howto/deployment/wsgi/ for more information. 138 | 139 | ## Usage 140 | 141 | ### Automatic requirement packaging 142 | 143 | You'll need to include any packages that your application uses in the bundle 144 | that's deployed to AWS Lambda. This plugin helps you out by doing this automatically, 145 | as long as you specify your required packages in a `requirements.txt` file in the root 146 | of your Serverless service path: 147 | 148 | ``` 149 | Flask==1.0.2 150 | requests==2.21.0 151 | ``` 152 | 153 | For more information, see https://pip.pypa.io/en/latest/user_guide/#requirements-files. 154 | 155 | The `serverless-wsgi` plugin itself depends on `werkzeug` and will package it automatically, 156 | even if `werkzeug` is not present in your `requirements.txt`. 157 | 158 | You can use the requirement packaging functionality of _serverless-wsgi_ without the WSGI 159 | handler itself by including the plugin in your `serverless.yml` configuration, without specifying 160 | the `custom.wsgi.app` setting. This will omit the WSGI handler from the package, but include 161 | any requirements specified in `requirements.txt`. 162 | 163 | If you don't want to use automatic requirement packaging you can set `custom.wsgi.packRequirements` to false: 164 | 165 | ```yaml 166 | custom: 167 | wsgi: 168 | app: api.app 169 | packRequirements: false 170 | ``` 171 | 172 | In order to pass additional arguments to `pip` when installing requirements, the `pipArgs` configuration 173 | option is available: 174 | 175 | ```yaml 176 | custom: 177 | wsgi: 178 | app: api.app 179 | pipArgs: --no-deps 180 | ``` 181 | 182 | For a more advanced approach to packaging requirements, consider using https://github.com/UnitedIncome/serverless-python-requirements. 183 | When the `serverless-python-requirements` is added to `serverless.yml`, the `packRequirements` option 184 | is set to `false` by default. 185 | 186 | If you have `packRequirements` set to `false`, or if you use `serverless-python-requirements`, remember to add 187 | `werkzeug` explicitly in your `requirements.txt`. 188 | 189 | ### Python version 190 | 191 | Python is used for packaging requirements and serving the app when invoking `sls wsgi serve`. By 192 | default, the current runtime setting is expected to be the name of the Python binary in `PATH`, 193 | for instance `python3.6`. If this is not the name of your Python binary, override it using the 194 | `pythonBin` option: 195 | 196 | ```yaml 197 | custom: 198 | wsgi: 199 | app: api.app 200 | pythonBin: python3 201 | ``` 202 | 203 | ### Local server 204 | 205 |

206 | 207 |

208 | 209 | For convenience, a `sls wsgi serve` command is provided to run your WSGI application 210 | locally. This command requires the `werkzeug` Python package to be installed, 211 | and acts as a simple wrapper for starting werkzeug's built-in HTTP server. 212 | 213 | By default, the server will start on port 5000. 214 | (Note: macOS [reserves port 5000](https://twitter.com/mitsuhiko/status/1462734023164416009) 215 | for AirPlay by default, see below for instructions on changing the port.) 216 | 217 | ``` 218 | $ sls wsgi serve 219 | * Running on http://localhost:5000/ (Press CTRL+C to quit) 220 | * Restarting with stat 221 | * Debugger is active! 222 | ``` 223 | 224 | Configure the port using the `-p` parameter: 225 | 226 | ``` 227 | $ sls wsgi serve -p 8000 228 | * Running on http://localhost:8000/ (Press CTRL+C to quit) 229 | * Restarting with stat 230 | * Debugger is active! 231 | ``` 232 | 233 | When running locally, an environment variable named `IS_OFFLINE` will be set to `True`. 234 | So, if you want to know when the application is running locally, check `os.environ["IS_OFFLINE"]`. 235 | 236 | ### Remote command execution 237 | 238 |

239 | 240 |

241 | 242 | The `wsgi exec` command lets you execute Python code remotely: 243 | 244 | ``` 245 | $ sls wsgi exec -c "import math; print((1 + math.sqrt(5)) / 2)" 246 | 1.618033988749895 247 | 248 | $ cat count.py 249 | for i in range(3): 250 | print(i) 251 | 252 | $ sls wsgi exec -f count.py 253 | 0 254 | 1 255 | 2 256 | ``` 257 | 258 | The `wsgi command` command lets you execute shell commands remotely: 259 | 260 | ``` 261 | $ sls wsgi command -c "pwd" 262 | /var/task 263 | 264 | $ cat script.sh 265 | #!/bin/bash 266 | echo "dlrow olleh" | rev 267 | 268 | $ sls wsgi command -f script.sh 269 | hello world 270 | ``` 271 | 272 | The `wsgi flask` command lets you execute [Flask CLI custom commands](http://flask.pocoo.org/docs/latest/cli/#custom-commands) remotely: 273 | 274 | ``` 275 | $ sls wsgi flask -c "my command" 276 | Hello world! 277 | ``` 278 | 279 | The `wsgi manage` command lets you execute Django management commands remotely: 280 | 281 | ``` 282 | $ sls wsgi manage -c "check --list-tags" 283 | admin 284 | caches 285 | database 286 | models 287 | staticfiles 288 | templates 289 | urls 290 | ``` 291 | 292 | All commands have `local` equivalents that let you run commands through `sls invoke local` rather 293 | than `sls invoke`, i.e. on the local machine instead of through Lambda. The `local` commands (`sls wsgi command local`, 294 | `sls wsgi exec local`, `sls wsgi flask local` and `sls wsgi manage local`) take the same arguments 295 | as their remote counterparts documented above. 296 | 297 | ### Explicit routes 298 | 299 | If you'd like to be explicit about which routes and HTTP methods should pass through to your 300 | application, see the following example: 301 | 302 | ```yaml 303 | service: example 304 | 305 | provider: 306 | name: aws 307 | runtime: python3.6 308 | 309 | plugins: 310 | - serverless-wsgi 311 | 312 | functions: 313 | api: 314 | handler: wsgi_handler.handler 315 | events: 316 | - http: 317 | path: cats 318 | method: get 319 | integration: lambda-proxy 320 | - http: 321 | path: dogs/{id} 322 | method: get 323 | integration: lambda-proxy 324 | 325 | custom: 326 | wsgi: 327 | app: api.app 328 | ``` 329 | 330 | ### Custom domain names 331 | 332 | If you use custom domain names with API Gateway, you might have a base path that is 333 | at the beginning of your path, such as the stage (`/dev`, `/stage`, `/prod`). In this case, set 334 | the `API_GATEWAY_BASE_PATH` environment variable to let `serverless-wsgi` know. 335 | E.g, if you deploy your WSGI application to https://mydomain.com/api/myservice, 336 | set `API_GATEWAY_BASE_PATH` to `api/myservice` (no `/` first). 337 | 338 | The example below uses the [serverless-domain-manager](https://github.com/amplify-education/serverless-domain-manager) 339 | plugin to handle custom domains in API Gateway: 340 | 341 | ```yaml 342 | service: example 343 | 344 | provider: 345 | name: aws 346 | runtime: python3.6 347 | environment: 348 | API_GATEWAY_BASE_PATH: ${self:custom.customDomain.basePath} 349 | 350 | plugins: 351 | - serverless-wsgi 352 | - serverless-domain-manager 353 | 354 | functions: 355 | api: 356 | handler: wsgi_handler.handler 357 | events: 358 | - http: ANY / 359 | - http: ANY {proxy+} 360 | 361 | custom: 362 | wsgi: 363 | app: api.app 364 | customDomain: 365 | basePath: ${opt:stage} 366 | domainName: mydomain.name.com 367 | stage: ${opt:stage} 368 | createRoute53Record: true 369 | ``` 370 | 371 | **Note**: The **API_GATEWAY_BASE_PATH** configuration is only needed when using the payload V1. In the V2, the path does not have the **basePath** in the beginning. 372 | 373 | ### Using CloudFront 374 | 375 | If you're configuring CloudFront manually in front of your API and setting 376 | the Path in the CloudFront Origin to include your stage name, you'll need 377 | to strip it out from the path supplied to WSGI. This is so that your app 378 | doesn't generate URLs starting with `/production`. 379 | 380 | Pass the `STRIP_STAGE_PATH=yes` environment variable to your application 381 | to set this: 382 | 383 | ```yaml 384 | service: example 385 | 386 | provider: 387 | name: aws 388 | runtime: python3.6 389 | environment: 390 | STRIP_STAGE_PATH: yes 391 | ``` 392 | 393 | ### File uploads 394 | 395 | In order to accept file uploads from HTML forms, make sure to add `multipart/form-data` to 396 | the list of content types with _Binary Support_ in your API Gateway API. The 397 | [serverless-apigw-binary](https://github.com/maciejtreder/serverless-apigw-binary) 398 | Serverless plugin can be used to automate this process. 399 | 400 | Keep in mind that, when building Serverless applications, uploading 401 | [directly to S3](http://docs.aws.amazon.com/AmazonS3/latest/dev/UsingHTTPPOST.html) 402 | from the browser is usually the preferred approach. 403 | 404 | ### Raw context and event 405 | 406 | The raw context and event from AWS Lambda are both accessible through the WSGI 407 | request. The following example shows how to access them when using Flask: 408 | 409 | ```python 410 | from flask import Flask, request 411 | app = Flask(__name__) 412 | 413 | 414 | @app.route("/") 415 | def index(): 416 | print(request.environ['serverless.context']) 417 | print(request.environ['serverless.event']) 418 | ``` 419 | 420 | For more information on these objects, read the documentation on [events](https://docs.aws.amazon.com/lambda/latest/dg/lambda-services.html) 421 | and the [invocation context](https://docs.aws.amazon.com/lambda/latest/dg/python-context.html). 422 | 423 | ### Text MIME types 424 | 425 | By default, all MIME types starting with `text/` and the following whitelist are sent 426 | through API Gateway in plain text. All other MIME types will have their response body 427 | base64 encoded (and the `isBase64Encoded` API Gateway flag set) in order to be 428 | delivered by API Gateway as binary data (remember to add any binary MIME types that 429 | you're using to the _Binary Support_ list in API Gateway). 430 | 431 | This is the default whitelist of plain text MIME types: 432 | 433 | - `application/json` 434 | - `application/javascript` 435 | - `application/xml` 436 | - `application/vnd.api+json` 437 | - `image/svg+xml` 438 | 439 | In order to add additional plain text MIME types to this whitelist, use the 440 | `textMimeTypes` configuration option: 441 | 442 | ```yaml 443 | custom: 444 | wsgi: 445 | app: api.app 446 | textMimeTypes: 447 | - application/custom+json 448 | - application/vnd.company+json 449 | ``` 450 | 451 | ### Preventing cold starts 452 | 453 | Common ways to keep lambda functions warm include [scheduled events](https://serverless.com/framework/docs/providers/aws/events/schedule/) 454 | and the [WarmUP plugin](https://github.com/FidelLimited/serverless-plugin-warmup). Both these event sources 455 | are supported by default and will be ignored by `serverless-wsgi`. 456 | 457 | ### Alternative directory structure 458 | 459 | If you have several functions in `serverless.yml` and want to organize them in 460 | directories, e.g.: 461 | 462 | ``` 463 | project 464 | ├── web 465 | │   ├── api.py 466 | │   └── requirements.txt 467 | ├── serverless.yml 468 | └── another_function.py 469 | ``` 470 | 471 | In this case, tell `serverless-wsgi` where to find the handler by prepending the 472 | directory: 473 | 474 | ```yaml 475 | service: example 476 | 477 | provider: 478 | name: aws 479 | runtime: python3.6 480 | 481 | plugins: 482 | - serverless-wsgi 483 | 484 | functions: 485 | api: 486 | handler: wsgi_handler.handler 487 | events: 488 | - http: ANY / 489 | - http: ANY {proxy+} 490 | 491 | another_function: 492 | handler: another_function.handler 493 | 494 | custom: 495 | wsgi: 496 | app: web/api.app 497 | ``` 498 | 499 | Requirements will now be installed into `web/`, rather than at in the service root directory. 500 | 501 | The same rule applies when using the `individually: true` flag in the `package` settings, together 502 | with the `module` option provided by `serverless-python-requirements`. In that case, both the requirements 503 | and the WSGI handler will be installed into `web/`, if the function is configured with `module: "web"`. 504 | 505 | ## Usage without Serverless 506 | 507 | The AWS API Gateway to WSGI mapping module is available on PyPI in the 508 | `serverless-wsgi` package. 509 | 510 | Use this package if you need to deploy Python Lambda functions to handle 511 | API Gateway events directly, without using the Serverless framework. 512 | 513 | ``` 514 | pip install serverless-wsgi 515 | ``` 516 | 517 | Initialize your WSGI application and in your Lambda event handler, call 518 | the request mapper: 519 | 520 | ```python 521 | import app # Replace with your actual application 522 | import serverless_wsgi 523 | 524 | # If you need to send additional content types as text, add then directly 525 | # to the whitelist: 526 | # 527 | # serverless_wsgi.TEXT_MIME_TYPES.append("application/custom+json") 528 | 529 | def handler(event, context): 530 | return serverless_wsgi.handle_request(app.app, event, context) 531 | ``` 532 | 533 | # Thanks 534 | 535 | Thanks to [Zappa](https://github.com/Miserlou/Zappa), which has been both the 536 | inspiration and source of several implementations that went into this project. 537 | 538 | Thanks to [chalice](https://github.com/awslabs/chalice) for the 539 | requirement packaging implementation. 540 | -------------------------------------------------------------------------------- /assets/header.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/serve.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 20 | 41 | 42 | 43 | sls wsgi serve sls wsgi serve sls wsgi serve sls wsgi serve sls wsgi serve sls wsgi serve sls wsgi serve sls wsgi serve sls wsgi serve sls wsgi serve sls wsgi serve sls wsgi serve sls wsgi serve sls wsgi serve ^C ~/demo sls wsgi serve Serverless: Using Python specified in "runtime": python3.6 * Running on http://localhost:5000/ (Press CTRL+C to quit) * Restarting with stat * Debugger is active! * Debugger PIN: 209-341-385^C 44 | -------------------------------------------------------------------------------- /devenv.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "devenv": { 4 | "locked": { 5 | "dir": "src/modules", 6 | "lastModified": 1696344641, 7 | "narHash": "sha256-cfGsdtDvzYaFA7oGWSgcd1yST6LFwvjMcHvtVj56VcU=", 8 | "owner": "cachix", 9 | "repo": "devenv", 10 | "rev": "05e26941f34486bff6ebeb4b9c169b6f637f1758", 11 | "type": "github" 12 | }, 13 | "original": { 14 | "dir": "src/modules", 15 | "owner": "cachix", 16 | "repo": "devenv", 17 | "type": "github" 18 | } 19 | }, 20 | "flake-compat": { 21 | "flake": false, 22 | "locked": { 23 | "lastModified": 1673956053, 24 | "narHash": "sha256-4gtG9iQuiKITOjNQQeQIpoIB6b16fm+504Ch3sNKLd8=", 25 | "owner": "edolstra", 26 | "repo": "flake-compat", 27 | "rev": "35bb57c0c8d8b62bbfd284272c928ceb64ddbde9", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "edolstra", 32 | "repo": "flake-compat", 33 | "type": "github" 34 | } 35 | }, 36 | "flake-utils": { 37 | "inputs": { 38 | "systems": "systems" 39 | }, 40 | "locked": { 41 | "lastModified": 1685518550, 42 | "narHash": "sha256-o2d0KcvaXzTrPRIo0kOLV0/QXHhDQ5DTi+OxcjO8xqY=", 43 | "owner": "numtide", 44 | "repo": "flake-utils", 45 | "rev": "a1720a10a6cfe8234c0e93907ffe81be440f4cef", 46 | "type": "github" 47 | }, 48 | "original": { 49 | "owner": "numtide", 50 | "repo": "flake-utils", 51 | "type": "github" 52 | } 53 | }, 54 | "gitignore": { 55 | "inputs": { 56 | "nixpkgs": [ 57 | "pre-commit-hooks", 58 | "nixpkgs" 59 | ] 60 | }, 61 | "locked": { 62 | "lastModified": 1660459072, 63 | "narHash": "sha256-8DFJjXG8zqoONA1vXtgeKXy68KdJL5UaXR8NtVMUbx8=", 64 | "owner": "hercules-ci", 65 | "repo": "gitignore.nix", 66 | "rev": "a20de23b925fd8264fd7fad6454652e142fd7f73", 67 | "type": "github" 68 | }, 69 | "original": { 70 | "owner": "hercules-ci", 71 | "repo": "gitignore.nix", 72 | "type": "github" 73 | } 74 | }, 75 | "nixpkgs": { 76 | "locked": { 77 | "lastModified": 1696419054, 78 | "narHash": "sha256-EdR+dIKCfqL3voZUDYwcvgRDOektQB9KbhBVcE0/3Mo=", 79 | "owner": "NixOS", 80 | "repo": "nixpkgs", 81 | "rev": "7131f3c223a2d799568e4b278380cd9dac2b8579", 82 | "type": "github" 83 | }, 84 | "original": { 85 | "owner": "NixOS", 86 | "ref": "nixpkgs-unstable", 87 | "repo": "nixpkgs", 88 | "type": "github" 89 | } 90 | }, 91 | "nixpkgs-stable": { 92 | "locked": { 93 | "lastModified": 1685801374, 94 | "narHash": "sha256-otaSUoFEMM+LjBI1XL/xGB5ao6IwnZOXc47qhIgJe8U=", 95 | "owner": "NixOS", 96 | "repo": "nixpkgs", 97 | "rev": "c37ca420157f4abc31e26f436c1145f8951ff373", 98 | "type": "github" 99 | }, 100 | "original": { 101 | "owner": "NixOS", 102 | "ref": "nixos-23.05", 103 | "repo": "nixpkgs", 104 | "type": "github" 105 | } 106 | }, 107 | "pre-commit-hooks": { 108 | "inputs": { 109 | "flake-compat": "flake-compat", 110 | "flake-utils": "flake-utils", 111 | "gitignore": "gitignore", 112 | "nixpkgs": [ 113 | "nixpkgs" 114 | ], 115 | "nixpkgs-stable": "nixpkgs-stable" 116 | }, 117 | "locked": { 118 | "lastModified": 1696158581, 119 | "narHash": "sha256-h0vY4E7Lx95lpYQbG2w4QH4yG5wCYOvPJzK93wVQbT0=", 120 | "owner": "cachix", 121 | "repo": "pre-commit-hooks.nix", 122 | "rev": "033453f85064ccac434dfd957f95d8457901ecd6", 123 | "type": "github" 124 | }, 125 | "original": { 126 | "owner": "cachix", 127 | "repo": "pre-commit-hooks.nix", 128 | "type": "github" 129 | } 130 | }, 131 | "root": { 132 | "inputs": { 133 | "devenv": "devenv", 134 | "nixpkgs": "nixpkgs", 135 | "pre-commit-hooks": "pre-commit-hooks" 136 | } 137 | }, 138 | "systems": { 139 | "locked": { 140 | "lastModified": 1681028828, 141 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 142 | "owner": "nix-systems", 143 | "repo": "default", 144 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 145 | "type": "github" 146 | }, 147 | "original": { 148 | "owner": "nix-systems", 149 | "repo": "default", 150 | "type": "github" 151 | } 152 | } 153 | }, 154 | "root": "root", 155 | "version": 7 156 | } 157 | -------------------------------------------------------------------------------- /devenv.nix: -------------------------------------------------------------------------------- 1 | { ... }: 2 | 3 | { 4 | languages.python.enable = true; 5 | languages.python.venv.enable = true; 6 | languages.javascript.enable = true; 7 | } 8 | -------------------------------------------------------------------------------- /devenv.yaml: -------------------------------------------------------------------------------- 1 | inputs: 2 | nixpkgs: 3 | url: github:NixOS/nixpkgs/nixpkgs-unstable 4 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const BbPromise = require("bluebird"); 4 | const _ = require("lodash"); 5 | const path = require("path"); 6 | const fse = BbPromise.promisifyAll(require("fs-extra")); 7 | const child_process = require("child_process"); 8 | const commandExists = require("command-exists"); 9 | const overrideStdoutWrite = require("process-utils/override-stdout-write"); 10 | 11 | class ServerlessWSGI { 12 | validate() { 13 | return new BbPromise((resolve) => { 14 | let handlersFixed = false; 15 | 16 | _.each(this.serverless.service.functions, (func) => { 17 | if (func.handler == "wsgi.handler") { 18 | func.handler = func.handler.replace( 19 | "wsgi.handler", 20 | "wsgi_handler.handler" 21 | ); 22 | handlersFixed = true; 23 | } 24 | }); 25 | 26 | if (handlersFixed) { 27 | this.serverless.cli.log( 28 | 'Warning: Please change "wsgi.handler" to "wsgi_handler.handler" in serverless.yml' 29 | ); 30 | this.serverless.cli.log( 31 | 'Warning: Using "wsgi.handler" still works but has been deprecated and will be removed' 32 | ); 33 | this.serverless.cli.log( 34 | "Warning: More information at https://github.com/logandk/serverless-wsgi/issues/84" 35 | ); 36 | } 37 | 38 | this.enableRequirements = !_.includes( 39 | this.serverless.service.plugins, 40 | "serverless-python-requirements" 41 | ); 42 | this.pipArgs = null; 43 | this.appPath = this.serverless.config.servicePath; 44 | 45 | if ( 46 | this.serverless.service.custom && 47 | this.serverless.service.custom.wsgi 48 | ) { 49 | if (this.serverless.service.custom.wsgi.app) { 50 | this.wsgiApp = this.serverless.service.custom.wsgi.app; 51 | this.appPath = path.dirname(path.join(this.appPath, this.wsgiApp)); 52 | } 53 | 54 | if (_.isBoolean(this.serverless.service.custom.wsgi.packRequirements)) { 55 | this.enableRequirements = 56 | this.serverless.service.custom.wsgi.packRequirements; 57 | } 58 | 59 | this.pipArgs = this.serverless.service.custom.wsgi.pipArgs; 60 | } 61 | 62 | if (this.enableRequirements) { 63 | this.requirementsInstallPath = path.join(this.appPath, ".requirements"); 64 | } 65 | 66 | this.packageRootPath = this.serverless.config.servicePath; 67 | 68 | if ( 69 | this.serverless.service.package && 70 | this.serverless.service.package.individually && 71 | this.serverless.config.servicePath != this.appPath 72 | ) { 73 | let handler = _.find(this.serverless.service.functions, (fun) => 74 | _.includes(fun.handler, "wsgi_handler.handler") 75 | ); 76 | 77 | // serverless-python-requirements supports packaging individual functions 78 | // by specifying a Python module, in which case the handler needs to be installed 79 | // in the module root, rather than the service root 80 | if (handler && handler.module) { 81 | this.packageRootPath = this.appPath; 82 | this.wsgiApp = path.basename(this.wsgiApp); 83 | } 84 | } 85 | 86 | resolve(); 87 | }); 88 | } 89 | 90 | configurePackaging() { 91 | return new BbPromise((resolve) => { 92 | this.serverless.service.package = this.serverless.service.package || {}; 93 | this.serverless.service.package.patterns = 94 | this.serverless.service.package.patterns || []; 95 | 96 | this.serverless.service.package.patterns = _.union( 97 | this.serverless.service.package.patterns, 98 | _.map( 99 | ["wsgi_handler.py", "serverless_wsgi.py", ".serverless-wsgi"], 100 | (artifact) => 101 | path.join( 102 | path.relative( 103 | this.serverless.config.servicePath, 104 | this.packageRootPath 105 | ), 106 | artifact 107 | ) 108 | ) 109 | ); 110 | 111 | if (this.enableRequirements) { 112 | this.serverless.service.package.patterns.push( 113 | `!${path.join( 114 | path.relative(this.serverless.config.servicePath, this.appPath), 115 | ".requirements/**" 116 | )}` 117 | ); 118 | } 119 | 120 | resolve(); 121 | }); 122 | } 123 | 124 | locatePython() { 125 | return new BbPromise((resolve) => { 126 | if ( 127 | this.serverless.service.custom && 128 | this.serverless.service.custom.wsgi && 129 | this.serverless.service.custom.wsgi.pythonBin 130 | ) { 131 | this.serverless.cli.log( 132 | `Using Python specified in "pythonBin": ${this.serverless.service.custom.wsgi.pythonBin}` 133 | ); 134 | 135 | this.pythonBin = this.serverless.service.custom.wsgi.pythonBin; 136 | return resolve(); 137 | } 138 | 139 | if (this.serverless.service.provider.runtime) { 140 | if (commandExists.sync(this.serverless.service.provider.runtime)) { 141 | this.serverless.cli.log( 142 | `Using Python specified in "runtime": ${this.serverless.service.provider.runtime}` 143 | ); 144 | 145 | this.pythonBin = this.serverless.service.provider.runtime; 146 | return resolve(); 147 | } else { 148 | this.serverless.cli.log( 149 | `Python executable not found for "runtime": ${this.serverless.service.provider.runtime}` 150 | ); 151 | } 152 | } 153 | 154 | this.serverless.cli.log("Using default Python executable: python"); 155 | 156 | this.pythonBin = "python"; 157 | 158 | resolve(); 159 | }); 160 | } 161 | 162 | getWsgiHandlerConfiguration() { 163 | const config = { app: this.wsgiApp }; 164 | 165 | if (_.isArray(this.serverless.service.custom.wsgi.textMimeTypes)) { 166 | config.text_mime_types = 167 | this.serverless.service.custom.wsgi.textMimeTypes; 168 | } 169 | 170 | return config; 171 | } 172 | 173 | packWsgiHandler(verbose = true) { 174 | if (!this.wsgiApp) { 175 | this.serverless.cli.log( 176 | "Warning: No WSGI app specified, omitting WSGI handler from package" 177 | ); 178 | return BbPromise.resolve(); 179 | } 180 | 181 | if (verbose) { 182 | this.serverless.cli.log("Packaging Python WSGI handler..."); 183 | } 184 | 185 | return BbPromise.all([ 186 | fse.copyAsync( 187 | path.resolve(__dirname, "wsgi_handler.py"), 188 | path.join(this.packageRootPath, "wsgi_handler.py") 189 | ), 190 | fse.copyAsync( 191 | path.resolve(__dirname, "serverless_wsgi.py"), 192 | path.join(this.packageRootPath, "serverless_wsgi.py") 193 | ), 194 | fse.writeFileAsync( 195 | path.join(this.packageRootPath, ".serverless-wsgi"), 196 | JSON.stringify(this.getWsgiHandlerConfiguration()) 197 | ), 198 | ]); 199 | } 200 | 201 | packRequirements() { 202 | return new BbPromise((resolve, reject) => { 203 | if (!this.enableRequirements) { 204 | return resolve(); 205 | } 206 | 207 | let args = [path.resolve(__dirname, "requirements.py")]; 208 | 209 | if (this.pipArgs) { 210 | args.push("--pip-args"); 211 | args.push(this.pipArgs); 212 | } 213 | 214 | if (this.wsgiApp) { 215 | args.push(path.resolve(__dirname, "requirements.txt")); 216 | } 217 | 218 | const requirementsFile = path.join(this.appPath, "requirements.txt"); 219 | 220 | if (fse.existsSync(requirementsFile)) { 221 | args.push(requirementsFile); 222 | } else { 223 | if (!this.wsgiApp) { 224 | return resolve(); 225 | } 226 | } 227 | 228 | args.push(this.requirementsInstallPath); 229 | 230 | this.serverless.cli.log("Packaging required Python packages..."); 231 | 232 | const res = child_process.spawnSync(this.pythonBin, args, { 233 | encoding: "utf8", 234 | }); 235 | if (res.error) { 236 | if (res.error.code == "ENOENT") { 237 | return reject( 238 | `Unable to run Python executable: ${this.pythonBin}. Use the "pythonBin" option to set your Python executable explicitly.` 239 | ); 240 | } else { 241 | return reject(res.error); 242 | } 243 | } 244 | 245 | if (res.status != 0) { 246 | return reject(res.stderr); 247 | } 248 | 249 | resolve(); 250 | }); 251 | } 252 | 253 | linkRequirements() { 254 | return new BbPromise((resolve, reject) => { 255 | if (!this.enableRequirements) { 256 | return resolve(); 257 | } 258 | 259 | if (fse.existsSync(this.requirementsInstallPath)) { 260 | this.serverless.cli.log("Linking required Python packages..."); 261 | 262 | fse.readdirSync(this.requirementsInstallPath).map((file) => { 263 | let relativePath = path.join( 264 | path.relative(this.serverless.config.servicePath, this.appPath), 265 | file 266 | ); 267 | 268 | this.serverless.service.package.patterns.push(relativePath); 269 | this.serverless.service.package.patterns.push(`${relativePath}/**`); 270 | 271 | try { 272 | fse.symlinkSync(`${this.requirementsInstallPath}/${file}`, file); 273 | } catch (exception) { 274 | let linkConflict = false; 275 | try { 276 | linkConflict = 277 | fse.readlinkSync(file) !== 278 | `${this.requirementsInstallPath}/${file}`; 279 | } catch (e) { 280 | linkConflict = true; 281 | } 282 | if (linkConflict) { 283 | return reject( 284 | `Unable to link dependency '${file}' ` + 285 | "because a file by the same name exists in this service" 286 | ); 287 | } 288 | } 289 | }); 290 | } 291 | 292 | resolve(); 293 | }); 294 | } 295 | 296 | checkWerkzeugPresent() { 297 | return new BbPromise((resolve) => { 298 | if (!this.wsgiApp || !this.enableRequirements) { 299 | return resolve(); 300 | } 301 | 302 | const hasWerkzeug = _.includes(fse.readdirSync(this.appPath), "werkzeug"); 303 | 304 | if (!hasWerkzeug) { 305 | this.serverless.cli.log( 306 | "Warning: Could not find werkzeug, please add it to your requirements.txt" 307 | ); 308 | } 309 | 310 | resolve(); 311 | }); 312 | } 313 | 314 | unlinkRequirements() { 315 | return new BbPromise((resolve) => { 316 | if (!this.enableRequirements) { 317 | return resolve(); 318 | } 319 | 320 | if (fse.existsSync(this.requirementsInstallPath)) { 321 | this.serverless.cli.log("Unlinking required Python packages..."); 322 | 323 | fse.readdirSync(this.requirementsInstallPath).map((file) => { 324 | if (fse.existsSync(file)) { 325 | fse.unlinkSync(file); 326 | } 327 | }); 328 | } 329 | 330 | resolve(); 331 | }); 332 | } 333 | 334 | cleanRequirements() { 335 | if (!this.enableRequirements) { 336 | return BbPromise.resolve(); 337 | } 338 | 339 | return fse.removeAsync(this.requirementsInstallPath); 340 | } 341 | 342 | cleanup() { 343 | const artifacts = [ 344 | "wsgi_handler.py", 345 | "serverless_wsgi.py", 346 | ".serverless-wsgi", 347 | ]; 348 | 349 | return BbPromise.all( 350 | _.map(artifacts, (artifact) => 351 | fse.removeAsync(path.join(this.packageRootPath, artifact)) 352 | ) 353 | ); 354 | } 355 | 356 | loadEnvVars() { 357 | return new BbPromise((resolve) => { 358 | const providerEnvVars = _.omitBy( 359 | this.serverless.service.provider.environment || {}, 360 | _.isObject 361 | ); 362 | _.merge(process.env, providerEnvVars); 363 | 364 | _.each(this.serverless.service.functions, (func) => { 365 | if (_.includes(func.handler, "wsgi_handler.handler")) { 366 | const functionEnvVars = _.omitBy(func.environment || {}, _.isObject); 367 | _.merge(process.env, functionEnvVars); 368 | } 369 | }); 370 | 371 | resolve(); 372 | }); 373 | } 374 | 375 | serve() { 376 | return new BbPromise((resolve, reject) => { 377 | if (!this.wsgiApp) { 378 | return reject( 379 | 'Missing WSGI app, please specify custom.wsgi.app. For instance, if you have a Flask application "app" in "api.py", set the Serverless custom.wsgi.app configuration option to: api.app' 380 | ); 381 | } 382 | 383 | const port = this.options.port || 5000; 384 | const host = this.options.host || "localhost"; 385 | const disable_threading = this.options["disable-threading"] || false; 386 | const num_processes = this.options["num-processes"] || 1; 387 | const ssl = this.options.ssl || false; 388 | const ssl_pub = this.options["ssl-pub"] || ""; 389 | const ssl_pri = this.options["ssl-pri"] || ""; 390 | 391 | var args = [ 392 | path.resolve(__dirname, "serve.py"), 393 | this.packageRootPath, 394 | this.wsgiApp, 395 | port, 396 | host, 397 | ]; 398 | 399 | if (num_processes > 1) { 400 | args.push("--num-processes", num_processes); 401 | } 402 | 403 | if (disable_threading) { 404 | args.push("--disable-threading"); 405 | } 406 | 407 | if (ssl) { 408 | args.push("--ssl"); 409 | } 410 | 411 | if (ssl_pub) { 412 | args.push("--ssl-pub", ssl_pub); 413 | } 414 | 415 | if (ssl_pri) { 416 | args.push("--ssl-pri", ssl_pri); 417 | } 418 | 419 | var status = child_process.spawnSync(this.pythonBin, args, { 420 | stdio: "inherit", 421 | }); 422 | if (status.error) { 423 | if (status.error.code == "ENOENT") { 424 | reject( 425 | `Unable to run Python executable: ${this.pythonBin}. Use the "pythonBin" option to set your Python executable explicitly.` 426 | ); 427 | } else { 428 | reject(status.error); 429 | } 430 | } else { 431 | resolve(); 432 | } 433 | }); 434 | } 435 | 436 | findHandler() { 437 | const functionName = this.options.function || this.options.f; 438 | 439 | if (functionName) { 440 | // If the function name is specified, return it directly 441 | if (this.serverless.service.functions[functionName]) { 442 | return functionName; 443 | } else { 444 | throw new Error(`Function "${functionName}" not found.`); 445 | } 446 | } else { 447 | return _.findKey(this.serverless.service.functions, (fun) => 448 | _.includes(fun.handler, "wsgi_handler.handler") 449 | ); 450 | } 451 | } 452 | 453 | invokeHandler(command, data, local) { 454 | let handlerFunction; 455 | try { 456 | handlerFunction = this.findHandler(); 457 | } catch (error) { 458 | return BbPromise.reject(error.message); 459 | } 460 | 461 | if (!handlerFunction) { 462 | return BbPromise.reject( 463 | "No functions were found with handler: wsgi_handler.handler" 464 | ); 465 | } 466 | 467 | // We're going to call the provider-agnostic invoke plugin, which has 468 | // no proper plugin-facing API. Instead, the current CLI options are modified 469 | // to match those of an invoke call. 470 | this.serverless.pluginManager.cliOptions.function = handlerFunction; 471 | this.options.function = handlerFunction; 472 | this.options.data = JSON.stringify({ 473 | "_serverless-wsgi": { 474 | command: command, 475 | data: data, 476 | }, 477 | }); 478 | this.serverless.pluginManager.cliOptions.data = JSON.stringify({ 479 | "_serverless-wsgi": { 480 | command: command, 481 | data: data, 482 | }, 483 | }); 484 | 485 | this.serverless.pluginManager.cliOptions.context = undefined; 486 | this.serverless.pluginManager.cliOptions.f = 487 | this.serverless.pluginManager.cliOptions.function; 488 | this.serverless.pluginManager.cliOptions.d = 489 | this.serverless.pluginManager.cliOptions.data; 490 | this.serverless.pluginManager.cliOptions.c = 491 | this.serverless.pluginManager.cliOptions.context; 492 | this.options.context = undefined; 493 | this.options.f = this.options.function; 494 | this.options.d = this.options.data; 495 | this.options.c = this.options.context; 496 | 497 | // The invoke plugin prints the response to the console as JSON. When invoking commands 498 | // remotely, we get a string back and we want it to appear in the console as it would have 499 | // if it was invoked locally. 500 | // 501 | // We capture stdout output in order to parse the array returned from the lambda invocation, 502 | // then restore stdout. 503 | let output = ""; 504 | 505 | /* eslint-disable no-unused-vars */ 506 | const { 507 | originalStdoutWrite, // Original `write` bound to `process.stdout`#noqa 508 | originalWrite, // Original `write` on its own 509 | restoreStdoutWrite, // Allows to restore previous state 510 | } = overrideStdoutWrite( 511 | // process.stdout.write replacement 512 | ( 513 | orig, // data input 514 | originalStdoutWrite // // Original `write` bound to `process.stdout` 515 | ) => { 516 | output += orig; 517 | } 518 | ); 519 | /* eslint-enable no-unused-vars */ 520 | 521 | 522 | return this.serverless.pluginManager 523 | .run(local ? ["invoke", "local"] : ["invoke"]) 524 | .then( 525 | () => 526 | new BbPromise((resolve, reject) => { 527 | output = _.trimEnd(output, "\n"); 528 | try { 529 | output = JSON.parse(output); 530 | } catch (e) { 531 | // Swallow exception 532 | } 533 | if (_.isArray(output) && output.length == 2) { 534 | const return_code = output[0]; 535 | const output_data = _.isString(output[1]) 536 | ? _.trimEnd(output[1], "\n") 537 | : output[1]; 538 | if (return_code == 0) { 539 | this.serverless.cli.log(output_data); 540 | } else { 541 | return reject(new this.serverless.classes.Error(output_data)); 542 | } 543 | } else { 544 | this.serverless.cli.log(output); 545 | } 546 | return resolve(); 547 | }) 548 | ) 549 | .finally(() => { 550 | restoreStdoutWrite(); 551 | }); 552 | /* eslint-enable no-console */ 553 | } 554 | 555 | command(local) { 556 | let data = null; 557 | 558 | if (this.options.command) { 559 | data = this.options.command; 560 | } else if (this.options.file) { 561 | data = fse.readFileSync(this.options.file, "utf8"); 562 | } else { 563 | return BbPromise.reject( 564 | "Please provide either a command (-c) or a file (-f)" 565 | ); 566 | } 567 | 568 | return this.invokeHandler("command", data, local); 569 | } 570 | 571 | exec(local) { 572 | let data = null; 573 | 574 | if (this.options.command) { 575 | data = this.options.command; 576 | } else if (this.options.file) { 577 | data = fse.readFileSync(this.options.file, "utf8"); 578 | } else { 579 | return BbPromise.reject( 580 | "Please provide either a command (-c) or a file (-f)" 581 | ); 582 | } 583 | 584 | return this.invokeHandler("exec", data, local); 585 | } 586 | 587 | manage(local) { 588 | return this.invokeHandler("manage", this.options.command, local); 589 | } 590 | 591 | flask(local) { 592 | return this.invokeHandler("flask", this.options.command, local); 593 | } 594 | 595 | constructor(serverless, options) { 596 | this.serverless = serverless; 597 | this.options = options; 598 | 599 | this.commands = { 600 | wsgi: { 601 | usage: "Deploy Python WSGI applications", 602 | lifecycleEvents: ["wsgi"], 603 | 604 | commands: { 605 | serve: { 606 | usage: "Serve the WSGI application locally", 607 | lifecycleEvents: ["serve"], 608 | options: { 609 | port: { 610 | type: "string", 611 | usage: "Local server port, defaults to 5000", 612 | shortcut: "p", 613 | }, 614 | host: { 615 | type: "string", 616 | usage: "Server host, defaults to 'localhost'", 617 | }, 618 | "disable-threading": { 619 | type: "boolean", 620 | usage: "Disables multi-threaded mode", 621 | }, 622 | "num-processes": { 623 | type: "string", 624 | usage: "Number of processes for server, defaults to 1", 625 | }, 626 | ssl: { 627 | type: "boolean", 628 | usage: "Enable local serving using HTTPS", 629 | }, 630 | "ssl-pub": { 631 | type: "string", 632 | usage: "local ssl pem file to use for ssl", 633 | }, 634 | "ssl-pri": { 635 | type: "string", 636 | usage: "local ssl pem file to use for ssl private key", 637 | }, 638 | }, 639 | }, 640 | install: { 641 | usage: "Install WSGI handler and requirements for local use", 642 | lifecycleEvents: ["install"], 643 | }, 644 | clean: { 645 | usage: "Remove cached requirements", 646 | lifecycleEvents: ["clean"], 647 | }, 648 | command: { 649 | usage: "Execute shell commands or scripts remotely", 650 | lifecycleEvents: ["command"], 651 | options: { 652 | command: { 653 | type: "string", 654 | usage: "Command to execute", 655 | shortcut: "c", 656 | }, 657 | file: { 658 | type: "string", 659 | usage: "Path to a shell script to execute", 660 | shortcut: "f", 661 | }, 662 | }, 663 | commands: { 664 | local: { 665 | usage: "Execute shell commands or scripts locally", 666 | lifecycleEvents: ["command"], 667 | options: { 668 | command: { 669 | type: "string", 670 | usage: "Command to execute", 671 | shortcut: "c", 672 | }, 673 | file: { 674 | type: "string", 675 | usage: "Path to a shell script to execute", 676 | shortcut: "f", 677 | }, 678 | }, 679 | }, 680 | }, 681 | }, 682 | exec: { 683 | usage: "Evaluate Python code remotely", 684 | lifecycleEvents: ["exec"], 685 | options: { 686 | command: { 687 | type: "string", 688 | usage: "Python code to execute", 689 | shortcut: "c", 690 | }, 691 | file: { 692 | type: "string", 693 | usage: "Path to a Python script to execute", 694 | shortcut: "f", 695 | }, 696 | }, 697 | commands: { 698 | local: { 699 | usage: "Evaluate Python code locally", 700 | lifecycleEvents: ["exec"], 701 | options: { 702 | command: { 703 | type: "string", 704 | usage: "Python code to execute", 705 | shortcut: "c", 706 | }, 707 | file: { 708 | type: "string", 709 | usage: "Path to a Python script to execute", 710 | shortcut: "f", 711 | }, 712 | }, 713 | }, 714 | }, 715 | }, 716 | manage: { 717 | usage: "Run Django management commands remotely", 718 | lifecycleEvents: ["manage"], 719 | options: { 720 | command: { 721 | type: "string", 722 | usage: "Management command", 723 | shortcut: "c", 724 | required: true, 725 | }, 726 | }, 727 | commands: { 728 | local: { 729 | usage: "Run Django management commands locally", 730 | lifecycleEvents: ["manage"], 731 | options: { 732 | command: { 733 | type: "string", 734 | usage: "Management command", 735 | shortcut: "c", 736 | required: true, 737 | }, 738 | }, 739 | }, 740 | }, 741 | }, 742 | flask: { 743 | usage: "Run Flask CLI commands remotely", 744 | lifecycleEvents: ["flask"], 745 | options: { 746 | command: { 747 | type: "string", 748 | usage: "Flask CLI command", 749 | shortcut: "c", 750 | required: true, 751 | }, 752 | }, 753 | commands: { 754 | local: { 755 | usage: "Run Flask CLI commands locally", 756 | lifecycleEvents: ["flask"], 757 | options: { 758 | command: { 759 | type: "string", 760 | usage: "Flask CLI command", 761 | shortcut: "c", 762 | required: true, 763 | }, 764 | }, 765 | }, 766 | }, 767 | }, 768 | }, 769 | }, 770 | }; 771 | 772 | const deployBeforeHook = () => 773 | BbPromise.bind(this) 774 | .then(this.validate) 775 | .then(this.configurePackaging) 776 | .then(this.locatePython) 777 | .then(this.packWsgiHandler) 778 | .then(this.packRequirements) 779 | .then(this.linkRequirements) 780 | .then(this.checkWerkzeugPresent); 781 | 782 | const deployBeforeHookWithoutHandler = () => 783 | BbPromise.bind(this) 784 | .then(this.validate) 785 | .then(this.configurePackaging) 786 | .then(this.locatePython) 787 | .then(this.packRequirements) 788 | .then(this.linkRequirements); 789 | 790 | const deployAfterHook = () => 791 | BbPromise.bind(this) 792 | .then(this.validate) 793 | .then(this.unlinkRequirements) 794 | .then(this.cleanup); 795 | 796 | this.hooks = { 797 | "wsgi:wsgi": () => { 798 | this.serverless.cli.generateCommandsHelp(["wsgi"]); 799 | return BbPromise.resolve(); 800 | }, 801 | 802 | "wsgi:serve:serve": () => 803 | BbPromise.bind(this) 804 | .then(this.validate) 805 | .then(this.locatePython) 806 | .then(this.loadEnvVars) 807 | .then(this.serve), 808 | 809 | "wsgi:install:install": deployBeforeHook, 810 | 811 | "wsgi:command:command": () => 812 | BbPromise.bind(this) 813 | .then(this.validate) 814 | .then(() => this.command(false)), 815 | "wsgi:command:local:command": () => 816 | BbPromise.bind(this) 817 | .then(this.validate) 818 | .then(() => this.command(true)), 819 | "wsgi:exec:exec": () => 820 | BbPromise.bind(this) 821 | .then(this.validate) 822 | .then(() => this.exec(false)), 823 | "wsgi:exec:local:exec": () => 824 | BbPromise.bind(this) 825 | .then(this.validate) 826 | .then(() => this.exec(true)), 827 | "wsgi:manage:manage": () => 828 | BbPromise.bind(this) 829 | .then(this.validate) 830 | .then(() => this.manage(false)), 831 | "wsgi:manage:local:manage": () => 832 | BbPromise.bind(this) 833 | .then(this.validate) 834 | .then(() => this.manage(true)), 835 | "wsgi:flask:flask": () => 836 | BbPromise.bind(this) 837 | .then(this.validate) 838 | .then(() => this.flask(false)), 839 | "wsgi:flask:local:flask": () => 840 | BbPromise.bind(this) 841 | .then(this.validate) 842 | .then(() => this.flask(true)), 843 | 844 | "wsgi:clean:clean": () => deployAfterHook().then(this.cleanRequirements), 845 | 846 | "before:package:createDeploymentArtifacts": deployBeforeHook, 847 | "after:package:createDeploymentArtifacts": deployAfterHook, 848 | 849 | "before:deploy:function:packageFunction": () => { 850 | if ( 851 | _.includes(this.options.functionObj.handler, "wsgi_handler.handler") 852 | ) { 853 | return deployBeforeHook(); 854 | } else { 855 | return deployBeforeHookWithoutHandler(); 856 | } 857 | }, 858 | "after:deploy:function:packageFunction": deployAfterHook, 859 | 860 | "before:offline:start:init": deployBeforeHook, 861 | "after:offline:start:end": deployAfterHook, 862 | 863 | "before:invoke:local:invoke": () => { 864 | const functionObj = this.serverless.service.getFunction( 865 | this.options.function 866 | ); 867 | 868 | return BbPromise.bind(this) 869 | .then(this.validate) 870 | .then(() => { 871 | if (_.includes(functionObj.handler, "wsgi_handler.handler")) { 872 | return this.packWsgiHandler(false); 873 | } else { 874 | return BbPromise.resolve(); 875 | } 876 | }); 877 | }, 878 | "after:invoke:local:invoke": () => 879 | BbPromise.bind(this).then(this.validate).then(this.cleanup), 880 | }; 881 | } 882 | } 883 | 884 | module.exports = ServerlessWSGI; 885 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serverless-wsgi", 3 | "version": "3.0.5", 4 | "engines": { 5 | "node": ">=10" 6 | }, 7 | "description": "Serverless WSGI Plugin", 8 | "author": "logan.dk", 9 | "license": "MIT", 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/logandk/serverless-wsgi" 13 | }, 14 | "keywords": [ 15 | "serverless", 16 | "wsgi", 17 | "flask", 18 | "serverless framework plugin", 19 | "serverless applications", 20 | "serverless plugins", 21 | "api gateway", 22 | "lambda", 23 | "aws", 24 | "aws lambda", 25 | "amazon", 26 | "amazon web services", 27 | "serverless.com" 28 | ], 29 | "files": [ 30 | "CHANGELOG.md", 31 | "index.js", 32 | "LICENSE", 33 | "package.json", 34 | "README.md", 35 | "requirements.py", 36 | "requirements.txt", 37 | "serve.py", 38 | "wsgi_handler.py", 39 | "serverless_wsgi.py" 40 | ], 41 | "main": "index.js", 42 | "bin": {}, 43 | "scripts": { 44 | "test": "istanbul cover -x '*.test.js' node_modules/mocha/bin/_mocha '*.test.js' -- -R spec", 45 | "lint": "eslint *.js", 46 | "pytest": "py.test --cov=serve --cov=requirements --cov=wsgi_handler --cov=serverless_wsgi --cov-report=html", 47 | "pylint": "flake8 --exclude node_modules,.devenv" 48 | }, 49 | "devDependencies": { 50 | "chai": "^4.3.10", 51 | "chai-as-promised": "^7.1.1", 52 | "eslint": "^8.50.0", 53 | "istanbul": "^0.4.5", 54 | "mocha": "^10.2.0", 55 | "sinon": "^16.0.0" 56 | }, 57 | "dependencies": { 58 | "bluebird": "^3.7.2", 59 | "command-exists": "^1.2.9", 60 | "fs-extra": "^11.2.0", 61 | "lodash": "^4.17.21", 62 | "process-utils": "^4.0.0" 63 | }, 64 | "peerDependences": { 65 | "serverless": "^2.32.0" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /requirements.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """ 4 | This module loads a `requirements.txt` and uses `virtualenv`/`pip` to 5 | install the required Python packages into the specified directory. 6 | 7 | Inspired by: https://github.com/awslabs/chalice 8 | 9 | Author: Logan Raarup 10 | """ 11 | import os 12 | import platform 13 | import shlex 14 | import shutil 15 | import subprocess 16 | import sys 17 | 18 | try: 19 | import virtualenv 20 | except ImportError: # pragma: no cover 21 | sys.exit("Unable to load virtualenv, please install") 22 | 23 | 24 | def package(req_files, target_dir, pip_args=""): 25 | venv_dir = os.path.join(target_dir, ".venv") 26 | tmp_dir = os.path.join(target_dir, ".tmp") 27 | 28 | for req_file in req_files: 29 | if not os.path.isfile(req_file): 30 | sys.exit("No requirements file found in: {}".format(req_file)) 31 | 32 | if os.path.exists(target_dir): 33 | if not os.path.isdir(target_dir): 34 | sys.exit("Existing non-directory found at: {}".format(target_dir)) 35 | shutil.rmtree(target_dir) 36 | os.mkdir(target_dir) 37 | 38 | if os.path.exists(venv_dir): 39 | shutil.rmtree(venv_dir) 40 | 41 | if os.path.exists(tmp_dir): 42 | shutil.rmtree(tmp_dir) 43 | 44 | if hasattr(virtualenv, "main"): 45 | original = sys.argv 46 | sys.argv = ["", venv_dir, "--quiet", "-p", sys.executable] 47 | try: 48 | virtualenv.main() 49 | finally: 50 | sys.argv = original 51 | else: 52 | virtualenv.cli_run([venv_dir, "--quiet", "-p", sys.executable]) 53 | 54 | if platform.system() == "Windows": 55 | pip_exe = os.path.join(venv_dir, "Scripts", "pip.exe") 56 | deps_dir = os.path.join(venv_dir, "Lib", "site-packages") 57 | else: 58 | pip_exe = os.path.join(venv_dir, "bin", "pip") 59 | lib_path = os.path.join(venv_dir, "lib") 60 | libs_dir_path_items = os.listdir(lib_path) 61 | directories = [ 62 | d for d in libs_dir_path_items if os.path.isdir(os.path.join(lib_path, d)) 63 | ] 64 | if len(directories) > 0: 65 | python_dir = directories[0] 66 | else: 67 | sys.exit("No python directory") 68 | deps_dir = os.path.join(venv_dir, "lib", python_dir, "site-packages") 69 | 70 | if not os.path.isfile(pip_exe): 71 | sys.exit("Pip not found in: {}".format(pip_exe)) 72 | 73 | for req_file in req_files: 74 | p = subprocess.Popen( 75 | [pip_exe, "install", "-r", req_file] + shlex.split(pip_args), 76 | stdout=subprocess.PIPE, 77 | ) 78 | p.communicate() 79 | if p.returncode != 0: 80 | sys.exit("Failed to install requirements from: {}".format(req_file)) 81 | 82 | if not os.path.isdir(deps_dir): 83 | sys.exit("Installed packages not found in: {}".format(deps_dir)) 84 | 85 | blacklist = [ 86 | "pip", 87 | "pip-*", 88 | "wheel", 89 | "wheel-*", 90 | "setuptools", 91 | "setuptools-*", 92 | "*.dist-info", 93 | "easy_install.*", 94 | "*.pyc", 95 | "__pycache__", 96 | "_virtualenv.*", 97 | "distutils-precedence.pth", 98 | "six.py", 99 | "zipp.py" 100 | ] 101 | 102 | shutil.copytree( 103 | deps_dir, tmp_dir, symlinks=False, ignore=shutil.ignore_patterns(*blacklist) 104 | ) 105 | for f in os.listdir(tmp_dir): 106 | target = os.path.join(target_dir, f) 107 | if os.path.isdir(target): 108 | shutil.rmtree(target) 109 | elif os.path.exists(target): 110 | os.remove(target) 111 | shutil.move(os.path.join(tmp_dir, f), target_dir) 112 | shutil.rmtree(venv_dir) 113 | shutil.rmtree(tmp_dir) 114 | 115 | 116 | if __name__ == "__main__": # pragma: no cover 117 | args = sys.argv 118 | pip_args = "" 119 | 120 | if len(args) > 3 and args[1] == "--pip-args": 121 | pip_args = args[2] 122 | args = args[3:] 123 | else: 124 | args = args[1:] 125 | 126 | if len(args) < 2: 127 | sys.exit( 128 | "Usage: {} --pip-args '--no-deps' REQ_FILE... TARGET_DIR".format( 129 | os.path.basename(sys.argv[0]) 130 | ) 131 | ) 132 | 133 | package(args[:-1], args[-1], pip_args) 134 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | werkzeug>2 2 | -------------------------------------------------------------------------------- /requirements_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import os 4 | import platform 5 | import pytest 6 | import requirements 7 | import shutil 8 | import subprocess 9 | import sys 10 | import virtualenv 11 | from functools import partial 12 | 13 | 14 | class PopenStub: 15 | def __init__(self, returncode): 16 | self.returncode = returncode 17 | 18 | def communicate(self): 19 | self.returncode = self.returncode 20 | 21 | 22 | @pytest.fixture 23 | def mock_virtualenv(monkeypatch): 24 | virtualenv_calls = [] 25 | 26 | if hasattr(virtualenv, "main"): 27 | 28 | def mock_virtualenv_main(): 29 | virtualenv_calls.append(sys.argv[:]) 30 | 31 | monkeypatch.setattr(virtualenv, "main", mock_virtualenv_main) 32 | else: 33 | 34 | def mock_virtualenv_cli_run(args): 35 | virtualenv_calls.append([""] + args) 36 | 37 | monkeypatch.setattr(virtualenv, "cli_run", mock_virtualenv_cli_run) 38 | 39 | return virtualenv_calls 40 | 41 | 42 | @pytest.fixture 43 | def mock_system(monkeypatch): 44 | calls = [] 45 | 46 | def mock_any(func, retval, *args, **kwargs): 47 | calls.append((func, args)) 48 | if hasattr(retval, "__call__"): 49 | return retval(*args) 50 | else: 51 | return retval 52 | 53 | monkeypatch.setattr( 54 | os, "listdir", partial(mock_any, "os.listdir", ["dir1", "dir2", "file1"]) 55 | ) 56 | monkeypatch.setattr(os, "mkdir", partial(mock_any, "os.mkdir", None)) 57 | monkeypatch.setattr(os, "remove", partial(mock_any, "os.remove", None)) 58 | monkeypatch.setattr(os.path, "isfile", partial(mock_any, "os.path.isfile", True)) 59 | monkeypatch.setattr( 60 | os.path, 61 | "isdir", 62 | partial(mock_any, "os.path.isdir", lambda filename: filename != "/tmp/file1"), 63 | ) 64 | monkeypatch.setattr(os.path, "exists", partial(mock_any, "os.path.exists", True)) 65 | monkeypatch.setattr(shutil, "rmtree", partial(mock_any, "shutil.rmtree", None)) 66 | monkeypatch.setattr(shutil, "copytree", partial(mock_any, "shutil.copytree", None)) 67 | monkeypatch.setattr(shutil, "move", partial(mock_any, "shutil.move", None)) 68 | monkeypatch.setattr( 69 | platform, "system", partial(mock_any, "platform.system", "Linux") 70 | ) 71 | monkeypatch.setattr( 72 | subprocess, "Popen", partial(mock_any, "subprocess.Popen", PopenStub(0)) 73 | ) 74 | 75 | return calls 76 | 77 | 78 | def test_package(mock_system, mock_virtualenv): 79 | requirements.package(["/path1/requirements.txt", "/path2/requirements.txt"], "/tmp") 80 | 81 | assert len(mock_virtualenv) == 1 82 | assert mock_virtualenv[0] == ["", "/tmp/.venv", "--quiet", "-p", sys.executable] 83 | 84 | # Checks that requirements files exist 85 | assert mock_system.pop(0) == ("os.path.isfile", ("/path1/requirements.txt",)) 86 | assert mock_system.pop(0) == ("os.path.isfile", ("/path2/requirements.txt",)) 87 | 88 | # Checks that output dir exists 89 | assert mock_system.pop(0) == ("os.path.exists", ("/tmp",)) 90 | assert mock_system.pop(0) == ("os.path.isdir", ("/tmp",)) 91 | assert mock_system.pop(0) == ("shutil.rmtree", ("/tmp",)) 92 | assert mock_system.pop(0) == ("os.mkdir", ("/tmp",)) 93 | 94 | # Checks and removes existing venv/tmp dirs 95 | assert mock_system.pop(0) == ("os.path.exists", ("/tmp/.venv",)) 96 | assert mock_system.pop(0) == ("shutil.rmtree", ("/tmp/.venv",)) 97 | assert mock_system.pop(0) == ("os.path.exists", ("/tmp/.tmp",)) 98 | assert mock_system.pop(0) == ("shutil.rmtree", ("/tmp/.tmp",)) 99 | 100 | # Looks up system type 101 | assert mock_system.pop(0) == ("platform.system", ()) 102 | 103 | # Looks for pip installation 104 | assert mock_system.pop(0) == ("os.listdir", ("/tmp/.venv/lib",)) 105 | 106 | assert mock_system.pop(0) == ("os.path.isdir", ("/tmp/.venv/lib/dir1",)) 107 | assert mock_system.pop(0) == ("os.path.isdir", ("/tmp/.venv/lib/dir2",)) 108 | assert mock_system.pop(0) == ("os.path.isdir", ("/tmp/.venv/lib/file1",)) 109 | 110 | assert mock_system.pop(0) == ("os.path.isfile", ("/tmp/.venv/bin/pip",)) 111 | 112 | # Invokes pip for package installation 113 | assert mock_system.pop(0) == ( 114 | "subprocess.Popen", 115 | (["/tmp/.venv/bin/pip", "install", "-r", "/path1/requirements.txt"],), 116 | ) 117 | assert mock_system.pop(0) == ( 118 | "subprocess.Popen", 119 | (["/tmp/.venv/bin/pip", "install", "-r", "/path2/requirements.txt"],), 120 | ) 121 | 122 | # Copies installed packages to temporary directory 123 | assert mock_system.pop(0) == ( 124 | "os.path.isdir", 125 | ("/tmp/.venv/lib/dir1/site-packages",), 126 | ) 127 | assert mock_system.pop(0) == ( 128 | "shutil.copytree", 129 | ("/tmp/.venv/lib/dir1/site-packages", "/tmp/.tmp"), 130 | ) 131 | 132 | # Lists installed packages 133 | assert mock_system.pop(0) == ("os.listdir", ("/tmp/.tmp",)) 134 | 135 | # Clears existing installation of package 1 136 | assert mock_system.pop(0) == ("os.path.isdir", ("/tmp/dir1",)) 137 | assert mock_system.pop(0) == ("shutil.rmtree", ("/tmp/dir1",)) 138 | 139 | # Moves package 1 into place 140 | assert mock_system.pop(0) == ("shutil.move", ("/tmp/.tmp/dir1", "/tmp")) 141 | 142 | # Clears existing installation of package 2 143 | assert mock_system.pop(0) == ("os.path.isdir", ("/tmp/dir2",)) 144 | assert mock_system.pop(0) == ("shutil.rmtree", ("/tmp/dir2",)) 145 | 146 | # Moves package 2 into place 147 | assert mock_system.pop(0) == ("shutil.move", ("/tmp/.tmp/dir2", "/tmp")) 148 | 149 | # Clears existing installation of package 3 150 | assert mock_system.pop(0) == ("os.path.isdir", ("/tmp/file1",)) 151 | assert mock_system.pop(0) == ("os.path.exists", ("/tmp/file1",)) 152 | assert mock_system.pop(0) == ("os.remove", ("/tmp/file1",)) 153 | 154 | # Moves package 3 into place 155 | assert mock_system.pop(0) == ("shutil.move", ("/tmp/.tmp/file1", "/tmp")) 156 | 157 | # Performs final cleanup 158 | assert mock_system.pop(0) == ("shutil.rmtree", ("/tmp/.venv",)) 159 | assert mock_system.pop(0) == ("shutil.rmtree", ("/tmp/.tmp",)) 160 | 161 | 162 | def test_package_missing_requirements_file(mock_system, mock_virtualenv, monkeypatch): 163 | monkeypatch.setattr(os.path, "isfile", lambda f: False) 164 | 165 | with pytest.raises(SystemExit): 166 | requirements.package(["/path1/requirements.txt"], "/tmp") 167 | 168 | 169 | def test_package_existing_target_file(mock_system, mock_virtualenv, monkeypatch): 170 | monkeypatch.setattr(os.path, "isdir", lambda f: False) 171 | 172 | with pytest.raises(SystemExit): 173 | requirements.package(["/path1/requirements.txt"], "/tmp") 174 | 175 | 176 | def test_package_missing_deps(mock_system, mock_virtualenv, monkeypatch): 177 | monkeypatch.setattr( 178 | os.path, "isdir", lambda f: f != "/tmp/.venv/lib/dir1/site-packages" 179 | ) 180 | 181 | with pytest.raises(SystemExit): 182 | requirements.package(["/path1/requirements.txt"], "/tmp") 183 | 184 | 185 | def test_package_missing_pip(mock_system, mock_virtualenv, monkeypatch): 186 | monkeypatch.setattr(os.path, "isfile", lambda f: f != "/tmp/.venv/bin/pip") 187 | 188 | with pytest.raises(SystemExit): 189 | requirements.package(["/path1/requirements.txt"], "/tmp") 190 | 191 | 192 | def test_package_missing_python_dir(mock_system, mock_virtualenv, monkeypatch): 193 | monkeypatch.setattr(os, "listdir", lambda f: []) 194 | 195 | with pytest.raises(SystemExit): 196 | requirements.package(["/path1/requirements.txt"], "/tmp") 197 | 198 | 199 | def test_package_windows(mock_system, mock_virtualenv, monkeypatch): 200 | monkeypatch.setattr(platform, "system", lambda: "Windows") 201 | 202 | requirements.package(["/path1/requirements.txt"], "/tmp") 203 | 204 | pip_calls = [c for c in mock_system if c[0] == "subprocess.Popen"] 205 | 206 | assert pip_calls[0] == ( 207 | "subprocess.Popen", 208 | (["/tmp/.venv/Scripts/pip.exe", "install", "-r", "/path1/requirements.txt"],), 209 | ) 210 | 211 | 212 | def test_pip_error(mock_system, mock_virtualenv, monkeypatch): 213 | monkeypatch.setattr(subprocess, "Popen", lambda *args, **kwargs: PopenStub(1)) 214 | 215 | with pytest.raises(SystemExit): 216 | requirements.package(["/path1/requirements.txt"], "/tmp") 217 | 218 | 219 | def test_package_with_pip_args(mock_system, mock_virtualenv): 220 | requirements.package( 221 | ["/path1/requirements.txt"], 222 | "/tmp", 223 | "--no-deps --imaginary-arg 'imaginary \"value\"'", 224 | ) 225 | 226 | # Invokes pip for package installation 227 | assert mock_system[15] == ( 228 | "subprocess.Popen", 229 | ( 230 | [ 231 | "/tmp/.venv/bin/pip", 232 | "install", 233 | "-r", 234 | "/path1/requirements.txt", 235 | "--no-deps", 236 | "--imaginary-arg", 237 | 'imaginary "value"', 238 | ], 239 | ), 240 | ) 241 | -------------------------------------------------------------------------------- /serve.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """ 4 | This module serves a WSGI application using werkzeug. 5 | 6 | Author: Logan Raarup 7 | """ 8 | import argparse 9 | import importlib 10 | import os 11 | import sys 12 | 13 | try: 14 | from werkzeug import serving 15 | except ImportError: # pragma: no cover 16 | sys.exit("Unable to import werkzeug (run: pip install werkzeug)") 17 | 18 | 19 | def parse_args(): # pragma: no cover 20 | parser = argparse.ArgumentParser(description="serverless-wsgi server") 21 | 22 | # Positional arguments for backwards compatibility 23 | parser.add_argument("cwd", help="Set current working directory for server") 24 | parser.add_argument("app", help="Full import path to WSGI app") 25 | parser.add_argument( 26 | "port", type=int, default=5000, help="Port for server to listen on" 27 | ) 28 | parser.add_argument( 29 | "host", default="localhost", help="Host/ip to bind the server to" 30 | ) 31 | 32 | # Concurrency options. 33 | # For backwards compatibility, threading is enabled by default and only one process is used. 34 | parser.add_argument("--disable-threading", action="store_false", dest="use_threads") 35 | parser.add_argument("--num-processes", type=int, dest="processes", default=1) 36 | 37 | # Optional serving using HTTPS and passing pub and pri key 38 | parser.add_argument("--ssl", action="store_true", dest="ssl") 39 | parser.add_argument("--ssl-pub", dest="ssl_pub") 40 | parser.add_argument("--ssl-pri", dest="ssl_pri") 41 | 42 | return parser.parse_args() 43 | 44 | 45 | def serve(cwd, app, port=5000, host="localhost", threaded=True, processes=1, ssl=False, ssl_keys=None): 46 | sys.path.insert(0, cwd) 47 | 48 | os.environ["IS_OFFLINE"] = "True" 49 | 50 | wsgi_fqn = app.rsplit(".", 1) 51 | wsgi_fqn_parts = wsgi_fqn[0].rsplit("/", 1) 52 | if len(wsgi_fqn_parts) == 2: 53 | sys.path.insert(0, os.path.join(cwd, wsgi_fqn_parts[0])) 54 | wsgi_module = importlib.import_module(wsgi_fqn_parts[-1]) 55 | wsgi_app = getattr(wsgi_module, wsgi_fqn[1]) 56 | 57 | if ssl: 58 | ssl_context = ssl_keys or "adhoc" 59 | else: 60 | ssl_context = None 61 | 62 | # Attempt to force Flask into debug mode 63 | try: 64 | wsgi_app.debug = True 65 | except: # noqa: E722 66 | pass 67 | 68 | serving.run_simple( 69 | host, 70 | int(port), 71 | wsgi_app, 72 | use_debugger=True, 73 | use_reloader=True, 74 | use_evalex=True, 75 | threaded=threaded, 76 | processes=processes, 77 | ssl_context=ssl_context, 78 | ) 79 | 80 | 81 | def _validate_ssl_keys(cert_file, private_key_file): 82 | if not cert_file and not private_key_file: 83 | return None 84 | if not cert_file or not private_key_file: 85 | sys.exit("Missing either cert file or private key file (hint: --ssl-pub and --ssl-pri )") 86 | if not os.path.exists(cert_file): 87 | sys.exit("Cert file can't be found") 88 | if not os.path.exists(private_key_file): 89 | sys.exit("Private key file can't be found") 90 | return (cert_file, private_key_file) 91 | 92 | 93 | if __name__ == "__main__": # pragma: no cover 94 | args = parse_args() 95 | 96 | serve( 97 | cwd=args.cwd, 98 | app=args.app, 99 | port=args.port, 100 | host=args.host, 101 | threaded=args.use_threads, 102 | processes=args.processes, 103 | ssl=args.ssl or (bool(args.ssl_pub) and bool(args.ssl_pri)), 104 | ssl_keys=_validate_ssl_keys(args.ssl_pub, args.ssl_pri) 105 | ) 106 | -------------------------------------------------------------------------------- /serve_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import importlib 4 | import pytest 5 | import serve 6 | import sys 7 | import os 8 | from werkzeug import serving 9 | 10 | 11 | class ObjectStub: 12 | def __init__(self, **kwds): 13 | self.__dict__.update(kwds) 14 | 15 | 16 | @pytest.fixture 17 | def mock_werkzeug(monkeypatch): 18 | stub = ObjectStub(lastcall=None) 19 | 20 | def mock_serving(host, port, app, **kwargs): 21 | stub.lastcall = ObjectStub(host=host, port=port, app=app, kwargs=kwargs) 22 | 23 | monkeypatch.setattr(serving, "run_simple", mock_serving) 24 | 25 | return stub 26 | 27 | 28 | @pytest.fixture 29 | def mock_importlib(monkeypatch): 30 | app = ObjectStub() 31 | app.app = ObjectStub() 32 | 33 | def mock_import_module(module): 34 | if app.app: 35 | app.app.module = module 36 | return app 37 | 38 | monkeypatch.setattr(importlib, "import_module", mock_import_module) 39 | 40 | return app 41 | 42 | 43 | @pytest.fixture 44 | def mock_path(monkeypatch): 45 | path = [] 46 | monkeypatch.setattr(sys, "path", path) 47 | return path 48 | 49 | 50 | @pytest.fixture 51 | def mock_os_path_exists(monkeypatch): 52 | mock_path = ObjectStub() 53 | mock_path.file_names_that_exist = [] 54 | 55 | def mock_exists(file_name): 56 | return file_name in mock_path.file_names_that_exist 57 | 58 | monkeypatch.setattr(os.path, 'exists', mock_exists) 59 | 60 | return mock_path 61 | 62 | 63 | def test_serve(mock_path, mock_importlib, mock_werkzeug): 64 | serve.serve("/tmp1", "app.app", "5000") 65 | assert len(mock_path) == 1 66 | assert mock_path[0] == "/tmp1" 67 | assert mock_werkzeug.lastcall.host == "localhost" 68 | assert mock_werkzeug.lastcall.port == 5000 69 | assert mock_werkzeug.lastcall.app.module == "app" 70 | assert mock_werkzeug.lastcall.app.debug 71 | assert mock_werkzeug.lastcall.kwargs == { 72 | "use_reloader": True, 73 | "use_debugger": True, 74 | "use_evalex": True, 75 | "threaded": True, 76 | "processes": 1, 77 | "ssl_context": None, 78 | } 79 | 80 | 81 | def test_serve_alternative_hostname(mock_path, mock_importlib, mock_werkzeug): 82 | serve.serve("/tmp1", "app.app", "5000", "0.0.0.0") 83 | assert len(mock_path) == 1 84 | assert mock_path[0] == "/tmp1" 85 | assert mock_werkzeug.lastcall.host == "0.0.0.0" 86 | assert mock_werkzeug.lastcall.port == 5000 87 | assert mock_werkzeug.lastcall.app.module == "app" 88 | assert mock_werkzeug.lastcall.app.debug 89 | assert mock_werkzeug.lastcall.kwargs == { 90 | "use_reloader": True, 91 | "use_debugger": True, 92 | "use_evalex": True, 93 | "threaded": True, 94 | "processes": 1, 95 | "ssl_context": None, 96 | } 97 | 98 | 99 | def test_serve_disable_threading(mock_path, mock_importlib, mock_werkzeug): 100 | serve.serve("/tmp1", "app.app", "5000", "0.0.0.0", threaded=False) 101 | assert len(mock_path) == 1 102 | assert mock_path[0] == "/tmp1" 103 | assert mock_werkzeug.lastcall.host == "0.0.0.0" 104 | assert mock_werkzeug.lastcall.port == 5000 105 | assert mock_werkzeug.lastcall.app.module == "app" 106 | assert mock_werkzeug.lastcall.app.debug 107 | assert mock_werkzeug.lastcall.kwargs == { 108 | "use_reloader": True, 109 | "use_debugger": True, 110 | "use_evalex": True, 111 | "threaded": False, 112 | "processes": 1, 113 | "ssl_context": None, 114 | } 115 | 116 | 117 | def test_serve_multiple_processes(mock_path, mock_importlib, mock_werkzeug): 118 | serve.serve("/tmp1", "app.app", "5000", "0.0.0.0", processes=10) 119 | assert len(mock_path) == 1 120 | assert mock_path[0] == "/tmp1" 121 | assert mock_werkzeug.lastcall.host == "0.0.0.0" 122 | assert mock_werkzeug.lastcall.port == 5000 123 | assert mock_werkzeug.lastcall.app.module == "app" 124 | assert mock_werkzeug.lastcall.app.debug 125 | assert mock_werkzeug.lastcall.kwargs == { 126 | "use_reloader": True, 127 | "use_debugger": True, 128 | "use_evalex": True, 129 | "threaded": True, 130 | "processes": 10, 131 | "ssl_context": None, 132 | } 133 | 134 | 135 | def test_serve_ssl(mock_path, mock_importlib, mock_werkzeug): 136 | serve.serve("/tmp1", "app.app", "5000", "0.0.0.0", threaded=False, ssl=True) 137 | assert len(mock_path) == 1 138 | assert mock_path[0] == "/tmp1" 139 | assert mock_werkzeug.lastcall.host == "0.0.0.0" 140 | assert mock_werkzeug.lastcall.port == 5000 141 | assert mock_werkzeug.lastcall.app.module == "app" 142 | assert mock_werkzeug.lastcall.app.debug 143 | assert mock_werkzeug.lastcall.kwargs == { 144 | "use_reloader": True, 145 | "use_debugger": True, 146 | "use_evalex": True, 147 | "threaded": False, 148 | "processes": 1, 149 | "ssl_context": "adhoc", 150 | } 151 | 152 | 153 | def test_serve_from_subdir(mock_path, mock_importlib, mock_werkzeug): 154 | serve.serve("/tmp2", "subdir/app.app", "5000") 155 | assert len(mock_path) == 2 156 | assert mock_path[0] == "/tmp2/subdir" 157 | assert mock_path[1] == "/tmp2" 158 | assert mock_werkzeug.lastcall.host == "localhost" 159 | assert mock_werkzeug.lastcall.port == 5000 160 | assert mock_werkzeug.lastcall.app.module == "app" 161 | assert mock_werkzeug.lastcall.app.debug 162 | assert mock_werkzeug.lastcall.kwargs == { 163 | "use_reloader": True, 164 | "use_debugger": True, 165 | "use_evalex": True, 166 | "threaded": True, 167 | "processes": 1, 168 | "ssl_context": None, 169 | } 170 | 171 | 172 | def test_serve_non_debuggable_app(mock_path, mock_importlib, mock_werkzeug): 173 | mock_importlib.app = None 174 | 175 | serve.serve("/tmp1", "app.app", "5000") 176 | assert mock_werkzeug.lastcall.app is None 177 | 178 | 179 | def test_validate_ssl_keys_with_no_keys_passed(): 180 | return_value = serve._validate_ssl_keys(None, None) 181 | assert return_value is None 182 | 183 | 184 | def test_validate_ssl_keys_with_sys_exit_for_missing_key(): 185 | with pytest.raises(SystemExit) as pytest_wrapped_e: 186 | serve._validate_ssl_keys('test.pem', None) 187 | 188 | assert pytest_wrapped_e.type == SystemExit 189 | assert pytest_wrapped_e.value.code == "Missing either cert file or private key file (hint: --ssl-pub and --ssl-pri )" 190 | 191 | 192 | def test_validate_ssl_keys_with_non_existant_cert_file(mock_os_path_exists): 193 | with pytest.raises(SystemExit) as pytest_wrapped_e: 194 | serve._validate_ssl_keys('test.pem', 'test-key.pem') 195 | 196 | assert pytest_wrapped_e.type == SystemExit 197 | assert pytest_wrapped_e.value.code == "Cert file can't be found" 198 | 199 | 200 | def test_validate_ssl_keys_with_non_existant_key_file(mock_os_path_exists): 201 | mock_os_path_exists.file_names_that_exist = ['test.pem'] 202 | with pytest.raises(SystemExit) as pytest_wrapped_e: 203 | serve._validate_ssl_keys('test.pem', 'test-key.pem') 204 | 205 | assert pytest_wrapped_e.type == SystemExit 206 | assert pytest_wrapped_e.value.code == "Private key file can't be found" 207 | 208 | 209 | def test_validate_ssl_keys_with_actual_keys(mock_os_path_exists): 210 | mock_os_path_exists.file_names_that_exist = ['test.pem', 'test-key.pem'] 211 | return_value = serve._validate_ssl_keys('test.pem', 'test-key.pem') 212 | 213 | assert type(return_value) is tuple 214 | assert return_value[0] == 'test.pem' 215 | assert return_value[1] == 'test-key.pem' 216 | -------------------------------------------------------------------------------- /serverless_wsgi.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """ 4 | This module converts an AWS API Gateway proxied request to a WSGI request. 5 | 6 | Inspired by: https://github.com/miserlou/zappa 7 | 8 | Author: Logan Raarup 9 | """ 10 | import base64 11 | import io 12 | import json 13 | import os 14 | import sys 15 | from urllib.parse import urlencode, unquote, unquote_plus 16 | 17 | from werkzeug.datastructures import Headers, iter_multi_items 18 | from werkzeug.http import HTTP_STATUS_CODES 19 | from werkzeug.wrappers import Response 20 | 21 | # List of MIME types that should not be base64 encoded. MIME types within `text/*` 22 | # are included by default. 23 | TEXT_MIME_TYPES = [ 24 | "application/json", 25 | "application/javascript", 26 | "application/xml", 27 | "application/vnd.api+json", 28 | "image/svg+xml", 29 | ] 30 | 31 | 32 | def all_casings(input_string): 33 | """ 34 | Permute all casings of a given string. 35 | A pretty algoritm, via @Amber 36 | http://stackoverflow.com/questions/6792803/finding-all-possible-case-permutations-in-python 37 | """ 38 | if not input_string: 39 | yield "" 40 | else: 41 | first = input_string[:1] 42 | if first.lower() == first.upper(): 43 | for sub_casing in all_casings(input_string[1:]): 44 | yield first + sub_casing 45 | else: 46 | for sub_casing in all_casings(input_string[1:]): 47 | yield first.lower() + sub_casing 48 | yield first.upper() + sub_casing 49 | 50 | 51 | def split_headers(headers): 52 | """ 53 | If there are multiple occurrences of headers, create case-mutated variations 54 | in order to pass them through APIGW. This is a hack that's currently 55 | needed. See: https://github.com/logandk/serverless-wsgi/issues/11 56 | Source: https://github.com/Miserlou/Zappa/blob/master/zappa/middleware.py 57 | """ 58 | new_headers = {} 59 | 60 | for key in headers.keys(): 61 | values = headers.get_all(key) 62 | if len(values) > 1: 63 | for value, casing in zip(values, all_casings(key)): 64 | new_headers[casing] = value 65 | elif len(values) == 1: 66 | new_headers[key] = values[0] 67 | 68 | return new_headers 69 | 70 | 71 | def group_headers(headers): 72 | new_headers = {} 73 | 74 | for key in headers.keys(): 75 | new_headers[key] = headers.get_all(key) 76 | 77 | return new_headers 78 | 79 | 80 | def is_alb_event(event): 81 | return event.get("requestContext", {}).get("elb") 82 | 83 | 84 | def encode_query_string(event): 85 | params = event.get("multiValueQueryStringParameters") 86 | if not params: 87 | params = event.get("queryStringParameters") 88 | if not params: 89 | params = event.get("query") 90 | if not params: 91 | params = "" 92 | if is_alb_event(event): 93 | params = [ 94 | (unquote_plus(k), unquote_plus(v)) 95 | for k, v in iter_multi_items(params) 96 | ] 97 | return urlencode(params, doseq=True) 98 | 99 | 100 | def get_script_name(headers, request_context): 101 | strip_stage_path = os.environ.get("STRIP_STAGE_PATH", "").lower().strip() in [ 102 | "yes", 103 | "y", 104 | "true", 105 | "t", 106 | "1", 107 | ] 108 | 109 | if "amazonaws.com" in headers.get("Host", "") and not strip_stage_path: 110 | script_name = "/{}".format(request_context.get("stage", "")) 111 | else: 112 | script_name = "" 113 | return script_name 114 | 115 | 116 | def get_body_bytes(event, body): 117 | if event.get("isBase64Encoded", False): 118 | body = base64.b64decode(body) 119 | if isinstance(body, str): 120 | body = body.encode("utf-8") 121 | return body 122 | 123 | 124 | def setup_environ_items(environ, headers): 125 | for key, value in environ.items(): 126 | if isinstance(value, str): 127 | environ[key] = value.encode("utf-8").decode("latin1", "replace") 128 | 129 | for key, value in headers.items(): 130 | key = "HTTP_" + key.upper().replace("-", "_") 131 | if key not in ("HTTP_CONTENT_TYPE", "HTTP_CONTENT_LENGTH"): 132 | environ[key] = value 133 | return environ 134 | 135 | 136 | def generate_response(response, event): 137 | returndict = {"statusCode": response.status_code} 138 | 139 | if "multiValueHeaders" in event and event["multiValueHeaders"]: 140 | returndict["multiValueHeaders"] = group_headers(response.headers) 141 | else: 142 | returndict["headers"] = split_headers(response.headers) 143 | 144 | if is_alb_event(event): 145 | # If the request comes from ALB we need to add a status description 146 | returndict["statusDescription"] = "%d %s" % ( 147 | response.status_code, 148 | HTTP_STATUS_CODES[response.status_code], 149 | ) 150 | 151 | if response.data: 152 | mimetype = response.mimetype or "text/plain" 153 | if ( 154 | mimetype.startswith("text/") or mimetype in TEXT_MIME_TYPES 155 | ) and not response.headers.get("Content-Encoding", ""): 156 | returndict["body"] = response.get_data(as_text=True) 157 | returndict["isBase64Encoded"] = False 158 | else: 159 | returndict["body"] = base64.b64encode( 160 | response.data).decode("utf-8") 161 | returndict["isBase64Encoded"] = True 162 | 163 | return returndict 164 | 165 | 166 | def strip_express_gateway_query_params(path): 167 | """Contrary to regular AWS lambda HTTP events, Express Gateway 168 | (https://github.com/ExpressGateway/express-gateway-plugin-lambda) 169 | adds query parameters to the path, which we need to strip. 170 | """ 171 | if "?" in path: 172 | path = path.split("?")[0] 173 | return path 174 | 175 | 176 | def handle_request(app, event, context): 177 | if event.get("source") in ["aws.events", "serverless-plugin-warmup"]: 178 | print("Lambda warming event received, skipping handler") 179 | return {} 180 | 181 | if ( 182 | event.get("version") is None 183 | and event.get("isBase64Encoded") is None 184 | and event.get("requestPath") is not None 185 | and not is_alb_event(event) 186 | ): 187 | return handle_lambda_integration(app, event, context) 188 | 189 | if event.get("version") == "2.0": 190 | return handle_payload_v2(app, event, context) 191 | 192 | return handle_payload_v1(app, event, context) 193 | 194 | 195 | def handle_payload_v1(app, event, context): 196 | if "multiValueHeaders" in event and event["multiValueHeaders"]: 197 | headers = Headers(event["multiValueHeaders"]) 198 | else: 199 | headers = Headers(event["headers"]) 200 | 201 | script_name = get_script_name(headers, event.get("requestContext", {})) 202 | 203 | # If a user is using a custom domain on API Gateway, they may have a base 204 | # path in their URL. This allows us to strip it out via an optional 205 | # environment variable. 206 | path_info = strip_express_gateway_query_params(event["path"]) 207 | base_path = os.environ.get("API_GATEWAY_BASE_PATH") 208 | if base_path: 209 | script_name = "/" + base_path 210 | 211 | if path_info.startswith(script_name): 212 | path_info = path_info[len(script_name):] 213 | 214 | body = event.get("body") or "" 215 | body = get_body_bytes(event, body) 216 | 217 | environ = { 218 | "CONTENT_LENGTH": str(len(body)), 219 | "CONTENT_TYPE": headers.get("Content-Type", ""), 220 | "PATH_INFO": unquote(path_info), 221 | "QUERY_STRING": encode_query_string(event), 222 | "REMOTE_ADDR": event.get("requestContext", {}) 223 | .get("identity", {}) 224 | .get("sourceIp", ""), 225 | "REMOTE_USER": (event.get("requestContext", {}) 226 | .get("authorizer") or {}) 227 | .get("principalId", ""), 228 | "REQUEST_METHOD": event.get("httpMethod", {}), 229 | "SCRIPT_NAME": script_name, 230 | "SERVER_NAME": headers.get("Host", "lambda"), 231 | "SERVER_PORT": headers.get("X-Forwarded-Port", "443"), 232 | "SERVER_PROTOCOL": "HTTP/1.1", 233 | "wsgi.errors": sys.stderr, 234 | "wsgi.input": io.BytesIO(body), 235 | "wsgi.multiprocess": False, 236 | "wsgi.multithread": False, 237 | "wsgi.run_once": False, 238 | "wsgi.url_scheme": headers.get("X-Forwarded-Proto", "https"), 239 | "wsgi.version": (1, 0), 240 | "serverless.authorizer": event.get("requestContext", {}).get("authorizer"), 241 | "serverless.event": event, 242 | "serverless.context": context, 243 | } 244 | 245 | environ = setup_environ_items(environ, headers) 246 | 247 | response = Response.from_app(app, environ) 248 | returndict = generate_response(response, event) 249 | 250 | return returndict 251 | 252 | 253 | def handle_payload_v2(app, event, context): 254 | headers = Headers(event["headers"]) 255 | 256 | script_name = get_script_name(headers, event.get("requestContext", {})) 257 | 258 | path_info = strip_express_gateway_query_params(event["rawPath"]) 259 | base_path = os.environ.get("API_GATEWAY_BASE_PATH") 260 | if base_path: 261 | script_name = "/" + base_path 262 | 263 | if path_info.startswith(script_name): 264 | path_info = path_info[len(script_name):] 265 | 266 | body = event.get("body", "") 267 | body = get_body_bytes(event, body) 268 | 269 | headers["Cookie"] = "; ".join(event.get("cookies", [])) 270 | 271 | environ = { 272 | "CONTENT_LENGTH": str(len(body or "")), 273 | "CONTENT_TYPE": headers.get("Content-Type", ""), 274 | "PATH_INFO": unquote(path_info), 275 | "QUERY_STRING": event.get("rawQueryString", ""), 276 | "REMOTE_ADDR": event.get("requestContext", {}) 277 | .get("http", {}) 278 | .get("sourceIp", ""), 279 | "REMOTE_USER": event.get("requestContext", {}) 280 | .get("authorizer", {}) 281 | .get("principalId", ""), 282 | "REQUEST_METHOD": event.get("requestContext", {}) 283 | .get("http", {}) 284 | .get("method", ""), 285 | "SCRIPT_NAME": script_name, 286 | "SERVER_NAME": headers.get("Host", "lambda"), 287 | "SERVER_PORT": headers.get("X-Forwarded-Port", "443"), 288 | "SERVER_PROTOCOL": "HTTP/1.1", 289 | "wsgi.errors": sys.stderr, 290 | "wsgi.input": io.BytesIO(body), 291 | "wsgi.multiprocess": False, 292 | "wsgi.multithread": False, 293 | "wsgi.run_once": False, 294 | "wsgi.url_scheme": headers.get("X-Forwarded-Proto", "https"), 295 | "wsgi.version": (1, 0), 296 | "serverless.authorizer": event.get("requestContext", {}).get("authorizer"), 297 | "serverless.event": event, 298 | "serverless.context": context, 299 | } 300 | 301 | environ = setup_environ_items(environ, headers) 302 | 303 | response = Response.from_app(app, environ) 304 | 305 | returndict = generate_response(response, event) 306 | 307 | return returndict 308 | 309 | 310 | def handle_lambda_integration(app, event, context): 311 | headers = Headers(event["headers"]) 312 | 313 | script_name = get_script_name(headers, event) 314 | 315 | path_info = strip_express_gateway_query_params(event["requestPath"]) 316 | 317 | for key, value in event.get("path", {}).items(): 318 | path_info = path_info.replace("{%s}" % key, value) 319 | path_info = path_info.replace("{%s+}" % key, value) 320 | 321 | body = event.get("body", {}) 322 | body = json.dumps(body) if body else "" 323 | body = get_body_bytes(event, body) 324 | 325 | environ = { 326 | "CONTENT_LENGTH": str(len(body or "")), 327 | "CONTENT_TYPE": headers.get("Content-Type", ""), 328 | "PATH_INFO": unquote(path_info), 329 | "QUERY_STRING": urlencode(event.get("query", {}), doseq=True), 330 | "REMOTE_ADDR": event.get("identity", {}).get("sourceIp", ""), 331 | "REMOTE_USER": event.get("principalId", ""), 332 | "REQUEST_METHOD": event.get("method", ""), 333 | "SCRIPT_NAME": script_name, 334 | "SERVER_NAME": headers.get("Host", "lambda"), 335 | "SERVER_PORT": headers.get("X-Forwarded-Port", "443"), 336 | "SERVER_PROTOCOL": "HTTP/1.1", 337 | "wsgi.errors": sys.stderr, 338 | "wsgi.input": io.BytesIO(body), 339 | "wsgi.multiprocess": False, 340 | "wsgi.multithread": False, 341 | "wsgi.run_once": False, 342 | "wsgi.url_scheme": headers.get("X-Forwarded-Proto", "https"), 343 | "wsgi.version": (1, 0), 344 | "serverless.authorizer": event.get("enhancedAuthContext"), 345 | "serverless.event": event, 346 | "serverless.context": context, 347 | } 348 | 349 | environ = setup_environ_items(environ, headers) 350 | 351 | response = Response.from_app(app, environ) 352 | 353 | returndict = generate_response(response, event) 354 | 355 | if response.status_code >= 300: 356 | raise RuntimeError(json.dumps(returndict)) 357 | 358 | return returndict 359 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [metadata] 5 | license_file = LICENSE 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setuptools.setup( 7 | name="serverless-wsgi", 8 | version="3.0.5", 9 | python_requires=">3.6", 10 | author="Logan Raarup", 11 | author_email="logan@logan.dk", 12 | description="Amazon AWS API Gateway WSGI wrapper", 13 | long_description=long_description, 14 | long_description_content_type="text/markdown", 15 | url="https://github.com/logandk/serverless-wsgi", 16 | py_modules=["serverless_wsgi"], 17 | install_requires=["werkzeug>2"], 18 | classifiers=( 19 | "Development Status :: 5 - Production/Stable", 20 | "Programming Language :: Python :: 3", 21 | "Intended Audience :: Developers", 22 | "License :: OSI Approved :: MIT License", 23 | "Operating System :: OS Independent", 24 | ), 25 | keywords="wsgi serverless aws lambda api gateway apigw flask django pyramid", 26 | ) 27 | -------------------------------------------------------------------------------- /wsgi_handler.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """ 4 | This module loads the WSGI application specified by FQN in `.serverless-wsgi` and invokes 5 | the request when the handler is called by AWS Lambda. 6 | 7 | Author: Logan Raarup 8 | """ 9 | import importlib 10 | import io 11 | import json 12 | import logging 13 | import os 14 | import sys 15 | import traceback 16 | from werkzeug.exceptions import InternalServerError 17 | 18 | # Call decompression helper from `serverless-python-requirements` if 19 | # available. See: https://github.com/UnitedIncome/serverless-python-requirements#dealing-with-lambdas-size-limitations 20 | try: 21 | import unzip_requirements # noqa 22 | except ImportError: 23 | pass 24 | 25 | import serverless_wsgi 26 | 27 | 28 | def load_config(): 29 | """Read the configuration file created during deployment""" 30 | root = os.path.abspath(os.path.dirname(__file__)) 31 | with open(os.path.join(root, ".serverless-wsgi"), "r") as f: 32 | return json.loads(f.read()) 33 | 34 | 35 | def import_app(config): 36 | """Load the application WSGI handler""" 37 | wsgi_fqn = config["app"].rsplit(".", 1) 38 | wsgi_fqn_parts = wsgi_fqn[0].rsplit("/", 1) 39 | 40 | if len(wsgi_fqn_parts) == 2: 41 | root = os.path.abspath(os.path.dirname(__file__)) 42 | sys.path.insert(0, os.path.join(root, wsgi_fqn_parts[0])) 43 | 44 | try: 45 | wsgi_module = importlib.import_module(wsgi_fqn_parts[-1]) 46 | 47 | return getattr(wsgi_module, wsgi_fqn[1]) 48 | except Exception as err: 49 | logging.exception("Unable to import app: '{}' - {}".format(config["app"], err)) 50 | return InternalServerError("Unable to import app: {}".format(config["app"])) 51 | 52 | 53 | def append_text_mime_types(config): 54 | """Append additional text (non-base64) mime types from configuration file""" 55 | if "text_mime_types" in config and isinstance(config["text_mime_types"], list): 56 | serverless_wsgi.TEXT_MIME_TYPES.extend(config["text_mime_types"]) 57 | 58 | 59 | def handler(event, context): 60 | """Lambda event handler, invokes the WSGI wrapper and handles command invocation""" 61 | if "_serverless-wsgi" in event: 62 | import shlex 63 | import subprocess 64 | 65 | native_stdout = sys.stdout 66 | native_stderr = sys.stderr 67 | output_buffer = io.StringIO() 68 | 69 | try: 70 | sys.stdout = output_buffer 71 | sys.stderr = output_buffer 72 | 73 | meta = event["_serverless-wsgi"] 74 | if meta.get("command") == "exec": 75 | # Evaluate Python code 76 | exec(meta.get("data", "")) 77 | elif meta.get("command") == "command": 78 | # Run shell commands 79 | result = subprocess.check_output( 80 | meta.get("data", ""), shell=True, stderr=subprocess.STDOUT 81 | ) 82 | output_buffer.write(result.decode()) 83 | elif meta.get("command") == "manage": 84 | # Run Django management commands 85 | from django.core import management 86 | 87 | management.call_command(*shlex.split(meta.get("data", ""))) 88 | elif meta.get("command") == "flask": 89 | # Run Flask CLI commands 90 | from flask.cli import FlaskGroup 91 | 92 | flask_group = FlaskGroup(create_app=_create_app) 93 | flask_group.main( 94 | shlex.split(meta.get("data", "")), standalone_mode=False 95 | ) 96 | else: 97 | raise Exception("Unknown command: {}".format(meta.get("command"))) 98 | except subprocess.CalledProcessError as e: 99 | return [e.returncode, e.output.decode("utf-8")] 100 | except: # noqa 101 | return [1, traceback.format_exc()] 102 | finally: 103 | sys.stdout = native_stdout 104 | sys.stderr = native_stderr 105 | 106 | return [0, output_buffer.getvalue()] 107 | else: 108 | return serverless_wsgi.handle_request(wsgi_app, event, context) 109 | 110 | 111 | def _create_app(): 112 | return wsgi_app 113 | 114 | 115 | # Read configuration and import the WSGI application 116 | config = load_config() 117 | wsgi_app = import_app(config) 118 | append_text_mime_types(config) 119 | -------------------------------------------------------------------------------- /wsgi_handler_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import builtins 4 | import importlib 5 | import json 6 | import os 7 | import pytest 8 | import sys 9 | from urllib.parse import urlencode 10 | from werkzeug.wrappers import Request, Response 11 | 12 | # Reference to open() before monkeypatching 13 | original_open = open 14 | 15 | # This workaround is needed for coverage.py to pick up the wsgi handler module 16 | try: 17 | import wsgi_handler # noqa: F401 18 | except: # noqa: E722 19 | pass 20 | 21 | 22 | class MockApp: 23 | def __init__(self): 24 | self.cookie_count = 3 25 | self.response_mimetype = "text/plain" 26 | self.status_code = 200 27 | 28 | def __call__(self, environ, start_response): 29 | self.last_environ = environ 30 | response = Response("Hello World ☃!", mimetype=self.response_mimetype) 31 | cookies = [ 32 | ("CUSTOMER", "WILE_E_COYOTE"), 33 | ("PART_NUMBER", "ROCKET_LAUNCHER_0002"), 34 | ("LOT_NUMBER", "42"), 35 | ] 36 | for cookie in cookies[: self.cookie_count]: 37 | response.set_cookie(cookie[0], cookie[1]) 38 | print("application debug #1", file=environ["wsgi.errors"]) 39 | response.status_code = self.status_code 40 | return response(environ, start_response) 41 | 42 | 43 | class MockFile: 44 | def __init__(self): 45 | self.contents = None 46 | 47 | def __enter__(self): 48 | return self 49 | 50 | def __exit__(self, *args): 51 | pass 52 | 53 | def read(self): 54 | return self.contents 55 | 56 | def write(self, data): 57 | self.contents = data 58 | 59 | 60 | class MockFileManager: 61 | def __init__(self): 62 | self.files = {} 63 | 64 | def open(self, name, mode="r", buffering=-1, **options): 65 | if name not in self.files: 66 | if mode.startswith("r"): # pragma: no cover 67 | return original_open(name, mode, buffering, **options) 68 | else: 69 | self.files[name] = MockFile() 70 | 71 | return self.files[name] 72 | 73 | 74 | @pytest.fixture 75 | def mock_app(monkeypatch): 76 | mock_app = MockApp() 77 | 78 | def mock_importlib(module): 79 | class MockObject: 80 | pass 81 | 82 | app = MockObject() 83 | app.app = mock_app 84 | app.app.module = module 85 | return app 86 | 87 | monkeypatch.setattr(importlib, "import_module", mock_importlib) 88 | 89 | return mock_app 90 | 91 | 92 | @pytest.fixture 93 | def mock_app_with_import_error(monkeypatch): 94 | def mock_importlib(module): 95 | raise ImportError("No module named {}".format(module)) 96 | 97 | monkeypatch.setattr(importlib, "import_module", mock_importlib) 98 | 99 | 100 | @pytest.fixture 101 | def mock_wsgi_app_file(monkeypatch): 102 | monkeypatch.setattr(os.path, "abspath", lambda x: "/tmp") 103 | 104 | manager = MockFileManager() 105 | with manager.open("/tmp/.serverless-wsgi", "w") as f: 106 | f.write(json.dumps({"app": "app.app"})) 107 | monkeypatch.setattr(builtins, "open", manager.open) 108 | 109 | 110 | @pytest.fixture 111 | def mock_subdir_wsgi_app_file(monkeypatch): 112 | monkeypatch.setattr(os.path, "abspath", lambda x: "/tmp") 113 | 114 | manager = MockFileManager() 115 | with manager.open("/tmp/.serverless-wsgi", "w") as f: 116 | f.write(json.dumps({"app": "subdir/app.app"})) 117 | monkeypatch.setattr(builtins, "open", manager.open) 118 | 119 | 120 | @pytest.fixture 121 | def mock_text_mime_wsgi_app_file(monkeypatch): 122 | monkeypatch.setattr(os.path, "abspath", lambda x: "/tmp") 123 | 124 | manager = MockFileManager() 125 | with manager.open("/tmp/.serverless-wsgi", "w") as f: 126 | f.write( 127 | json.dumps( 128 | {"app": "app.app", "text_mime_types": [ 129 | "application/custom+json"]} 130 | ) 131 | ) 132 | monkeypatch.setattr(builtins, "open", manager.open) 133 | 134 | 135 | @pytest.fixture 136 | def event_v1(): 137 | return { 138 | "body": None, 139 | "headers": { 140 | "Accept": "*/*", 141 | "Accept-Encoding": "gzip, deflate", 142 | "CloudFront-Forwarded-Proto": "https", 143 | "CloudFront-Is-Desktop-Viewer": "true", 144 | "CloudFront-Is-Mobile-Viewer": "false", 145 | "CloudFront-Is-SmartTV-Viewer": "false", 146 | "CloudFront-Is-Tablet-Viewer": "false", 147 | "CloudFront-Viewer-Country": "DK", 148 | "Cookie": "CUSTOMER=WILE_E_COYOTE; PART_NUMBER=ROCKET_LAUNCHER_0001", 149 | "Host": "3z6kd9fbb1.execute-api.us-east-1.amazonaws.com", 150 | "Postman-Token": "778a706e-d6b0-48d5-94dd-9e98c22f12fe", 151 | "User-Agent": "PostmanRuntime/3.0.11-hotfix.2", 152 | "Via": "1.1 b8fa.cloudfront.net (CloudFront)", 153 | "X-Amz-Cf-Id": "jx0Bvz9rm--Mz3wAj4i46FdOQQK3RHF4H0moJjBsQ==", 154 | "X-Amzn-Trace-Id": "Root=1-58d534a5-1e7cffe644b086304dce7a1e", 155 | "X-Forwarded-For": "76.20.166.147, 205.251.218.72", 156 | "X-Forwarded-Port": "443", 157 | "X-Forwarded-Proto": "https", 158 | "cache-control": "no-cache", 159 | }, 160 | "httpMethod": "GET", 161 | "isBase64Encoded": False, 162 | "path": "/some/path", 163 | "pathParameters": {"proxy": "some/path"}, 164 | "queryStringParameters": {"param1": "value1", "param2": "value2"}, 165 | "requestContext": { 166 | "accountId": "16794", 167 | "apiId": "3z6kd9fbb1", 168 | "httpMethod": "GET", 169 | "identity": { 170 | "accessKey": None, 171 | "accountId": None, 172 | "apiKey": None, 173 | "caller": None, 174 | "cognitoAuthenticationProvider": None, 175 | "cognitoAuthenticationType": None, 176 | "cognitoIdentityId": None, 177 | "cognitoIdentityPoolId": None, 178 | "sourceIp": "76.20.166.147", 179 | "user": None, 180 | "userAgent": "PostmanRuntime/3.0.11-hotfix.2", 181 | "userArn": None, 182 | }, 183 | "authorizer": {"principalId": "wile_e_coyote"}, 184 | "requestId": "ad2db740-10a2-11e7-8ced-35048084babb", 185 | "resourceId": "r4kza9", 186 | "resourcePath": "/{proxy+}", 187 | "stage": "dev", 188 | }, 189 | "resource": "/{proxy+}", 190 | "stageVariables": None, 191 | } 192 | 193 | 194 | @pytest.fixture 195 | def event_v1_offline(event_v1): 196 | event = event_v1 197 | del event["body"] 198 | return event 199 | 200 | 201 | @pytest.fixture 202 | def elb_event(): 203 | return { 204 | "requestContext": { 205 | "elb": { 206 | "targetGroupArn": "arn:aws:elasticloadbalancing:us-east-1:12345:targetgroup/xxxx/5e43816d76759862" 207 | } 208 | }, 209 | "httpMethod": "GET", 210 | "path": "/cats", 211 | "queryStringParameters": {}, 212 | "headers": { 213 | "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8", 214 | "accept-encoding": "gzip, deflate", 215 | "accept-language": "en-US,en;q=0.9,da;q=0.8", 216 | "cache-control": "max-age=0", 217 | "connection": "keep-alive", 218 | "host": "xxxx-203391234.us-east-1.elb.amazonaws.com", 219 | "upgrade-insecure-requests": "1", 220 | "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36", 221 | "x-amzn-trace-id": "Root=1-5f05949b-77e2b0f9434e2acbf5ad8ce8", 222 | "x-forwarded-for": "95.181.37.218", 223 | "x-forwarded-port": "80", 224 | "x-forwarded-proto": "http", 225 | }, 226 | "body": "", 227 | "isBase64Encoded": False, 228 | } 229 | 230 | 231 | @pytest.fixture # noqa: F811 232 | def wsgi_handler(): # noqa: F811 233 | if "wsgi_handler" in sys.modules: 234 | del sys.modules["wsgi_handler"] 235 | import wsgi_handler 236 | 237 | return wsgi_handler 238 | 239 | 240 | def test_handler(mock_wsgi_app_file, mock_app, event_v1, capsys, wsgi_handler): 241 | response = wsgi_handler.handler(event_v1, {"memory_limit_in_mb": "128"}) 242 | 243 | assert response == { 244 | "body": "Hello World ☃!", 245 | "headers": { 246 | "set-cookie": "CUSTOMER=WILE_E_COYOTE; Path=/", 247 | "Content-Length": "16", 248 | "Content-Type": "text/plain; charset=utf-8", 249 | "sEt-cookie": "LOT_NUMBER=42; Path=/", 250 | "Set-cookie": "PART_NUMBER=ROCKET_LAUNCHER_0002; Path=/", 251 | }, 252 | "statusCode": 200, 253 | "isBase64Encoded": False, 254 | } 255 | 256 | assert wsgi_handler.wsgi_app.last_environ == { 257 | "CONTENT_LENGTH": "0", 258 | "CONTENT_TYPE": "", 259 | "HTTP_ACCEPT": "*/*", 260 | "HTTP_ACCEPT_ENCODING": "gzip, deflate", 261 | "HTTP_CACHE_CONTROL": "no-cache", 262 | "HTTP_CLOUDFRONT_FORWARDED_PROTO": "https", 263 | "HTTP_CLOUDFRONT_IS_DESKTOP_VIEWER": "true", 264 | "HTTP_CLOUDFRONT_IS_MOBILE_VIEWER": "false", 265 | "HTTP_CLOUDFRONT_IS_SMARTTV_VIEWER": "false", 266 | "HTTP_CLOUDFRONT_IS_TABLET_VIEWER": "false", 267 | "HTTP_CLOUDFRONT_VIEWER_COUNTRY": "DK", 268 | "HTTP_COOKIE": "CUSTOMER=WILE_E_COYOTE; PART_NUMBER=ROCKET_LAUNCHER_0001", 269 | "HTTP_HOST": "3z6kd9fbb1.execute-api.us-east-1.amazonaws.com", 270 | "HTTP_POSTMAN_TOKEN": "778a706e-d6b0-48d5-94dd-9e98c22f12fe", 271 | "HTTP_USER_AGENT": "PostmanRuntime/3.0.11-hotfix.2", 272 | "HTTP_VIA": "1.1 b8fa.cloudfront.net (CloudFront)", 273 | "HTTP_X_AMZN_TRACE_ID": "Root=1-58d534a5-1e7cffe644b086304dce7a1e", 274 | "HTTP_X_AMZ_CF_ID": "jx0Bvz9rm--Mz3wAj4i46FdOQQK3RHF4H0moJjBsQ==", 275 | "HTTP_X_FORWARDED_FOR": "76.20.166.147, 205.251.218.72", 276 | "HTTP_X_FORWARDED_PORT": "443", 277 | "HTTP_X_FORWARDED_PROTO": "https", 278 | "PATH_INFO": "/some/path", 279 | "QUERY_STRING": urlencode(event_v1["queryStringParameters"], doseq=True), 280 | "REMOTE_ADDR": "76.20.166.147", 281 | "REMOTE_USER": "wile_e_coyote", 282 | "REQUEST_METHOD": "GET", 283 | "SCRIPT_NAME": "/dev", 284 | "SERVER_NAME": "3z6kd9fbb1.execute-api.us-east-1.amazonaws.com", 285 | "SERVER_PORT": "443", 286 | "SERVER_PROTOCOL": "HTTP/1.1", 287 | "wsgi.errors": wsgi_handler.wsgi_app.last_environ["wsgi.errors"], 288 | "wsgi.input": wsgi_handler.wsgi_app.last_environ["wsgi.input"], 289 | "wsgi.multiprocess": False, 290 | "wsgi.multithread": False, 291 | "wsgi.run_once": False, 292 | "wsgi.url_scheme": "https", 293 | "wsgi.version": (1, 0), 294 | "serverless.authorizer": {"principalId": "wile_e_coyote"}, 295 | "serverless.context": {"memory_limit_in_mb": "128"}, 296 | "serverless.event": event_v1, 297 | } 298 | 299 | out, err = capsys.readouterr() 300 | assert out == "" 301 | assert err == "application debug #1\n" 302 | 303 | 304 | def test_handler_offline(mock_wsgi_app_file, mock_app, event_v1_offline, wsgi_handler): 305 | response = wsgi_handler.handler( 306 | event_v1_offline, {"memory_limit_in_mb": "128"}) 307 | 308 | assert response["body"] == "Hello World ☃!" 309 | 310 | 311 | def test_handler_multivalue( 312 | mock_wsgi_app_file, mock_app, event_v1, capsys, wsgi_handler 313 | ): 314 | event_v1["multiValueQueryStringParameters"] = { 315 | "param1": ["value1"], 316 | "param2": ["value2", "value3"], 317 | } 318 | 319 | # Convert regular headers request to multiValueHeaders 320 | multi_headers = {} 321 | for key, value in event_v1["headers"].items(): 322 | if key not in multi_headers: 323 | multi_headers[key] = [] 324 | multi_headers[key].append(value) 325 | 326 | event_v1["multiValueHeaders"] = multi_headers 327 | response = wsgi_handler.handler(event_v1, {"memory_limit_in_mb": "128"}) 328 | query_string = wsgi_handler.wsgi_app.last_environ["QUERY_STRING"] 329 | 330 | print(query_string) 331 | assert query_string == urlencode( 332 | [ 333 | (i, k) 334 | for i, j in event_v1["multiValueQueryStringParameters"].items() 335 | for k in j 336 | ], 337 | doseq=True 338 | ) 339 | 340 | assert response == { 341 | "body": "Hello World ☃!", 342 | "multiValueHeaders": { 343 | "Content-Length": ["16"], 344 | "Content-Type": ["text/plain; charset=utf-8"], 345 | "Set-Cookie": [ 346 | "CUSTOMER=WILE_E_COYOTE; Path=/", 347 | "PART_NUMBER=ROCKET_LAUNCHER_0002; Path=/", 348 | "LOT_NUMBER=42; Path=/", 349 | ], 350 | }, 351 | "statusCode": 200, 352 | "isBase64Encoded": False, 353 | } 354 | 355 | 356 | def test_handler_china(mock_wsgi_app_file, mock_app, event_v1, capsys, wsgi_handler): 357 | event_v1["headers"]["Host"] = "x.amazonaws.com.cn" 358 | wsgi_handler.handler(event_v1, {"memory_limit_in_mb": "128"}) 359 | 360 | assert wsgi_handler.wsgi_app.last_environ["SCRIPT_NAME"] == "/dev" 361 | 362 | 363 | def test_handler_single_cookie(mock_wsgi_app_file, mock_app, event_v1, wsgi_handler): 364 | wsgi_handler.wsgi_app.cookie_count = 1 365 | response = wsgi_handler.handler(event_v1, {}) 366 | 367 | assert response == { 368 | "body": "Hello World ☃!", 369 | "headers": { 370 | "Set-Cookie": "CUSTOMER=WILE_E_COYOTE; Path=/", 371 | "Content-Length": "16", 372 | "Content-Type": "text/plain; charset=utf-8", 373 | }, 374 | "statusCode": 200, 375 | "isBase64Encoded": False, 376 | } 377 | 378 | 379 | def test_handler_no_cookie(mock_wsgi_app_file, mock_app, event_v1, wsgi_handler): 380 | wsgi_handler.wsgi_app.cookie_count = 0 381 | response = wsgi_handler.handler(event_v1, {}) 382 | 383 | assert response == { 384 | "body": "Hello World ☃!", 385 | "headers": { 386 | "Content-Length": "16", 387 | "Content-Type": "text/plain; charset=utf-8", 388 | }, 389 | "statusCode": 200, 390 | "isBase64Encoded": False, 391 | } 392 | 393 | 394 | def test_handler_schedule(mock_wsgi_app_file, mock_app, event_v1, wsgi_handler): 395 | event_v1 = {"source": "aws.events"} 396 | response = wsgi_handler.handler(event_v1, {}) 397 | assert response == {} 398 | 399 | 400 | def test_handler_warmup_plugin( 401 | mock_wsgi_app_file, mock_app, event_v1, wsgi_handler, capsys 402 | ): 403 | event_v1 = {"source": "serverless-plugin-warmup"} 404 | response = wsgi_handler.handler(event_v1, {}) 405 | assert response == {} 406 | 407 | out, err = capsys.readouterr() 408 | assert out == "Lambda warming event received, skipping handler\n" 409 | assert err == "" 410 | 411 | 412 | def test_handler_custom_domain(mock_wsgi_app_file, mock_app, event_v1, wsgi_handler): 413 | event_v1["headers"]["Host"] = "custom.domain.com" 414 | wsgi_handler.handler(event_v1, {}) 415 | 416 | assert wsgi_handler.wsgi_app.last_environ == { 417 | "CONTENT_LENGTH": "0", 418 | "CONTENT_TYPE": "", 419 | "HTTP_ACCEPT": "*/*", 420 | "HTTP_ACCEPT_ENCODING": "gzip, deflate", 421 | "HTTP_CACHE_CONTROL": "no-cache", 422 | "HTTP_CLOUDFRONT_FORWARDED_PROTO": "https", 423 | "HTTP_CLOUDFRONT_IS_DESKTOP_VIEWER": "true", 424 | "HTTP_CLOUDFRONT_IS_MOBILE_VIEWER": "false", 425 | "HTTP_CLOUDFRONT_IS_SMARTTV_VIEWER": "false", 426 | "HTTP_CLOUDFRONT_IS_TABLET_VIEWER": "false", 427 | "HTTP_CLOUDFRONT_VIEWER_COUNTRY": "DK", 428 | "HTTP_COOKIE": "CUSTOMER=WILE_E_COYOTE; PART_NUMBER=ROCKET_LAUNCHER_0001", 429 | "HTTP_HOST": "custom.domain.com", 430 | "HTTP_POSTMAN_TOKEN": "778a706e-d6b0-48d5-94dd-9e98c22f12fe", 431 | "HTTP_USER_AGENT": "PostmanRuntime/3.0.11-hotfix.2", 432 | "HTTP_VIA": "1.1 b8fa.cloudfront.net (CloudFront)", 433 | "HTTP_X_AMZN_TRACE_ID": "Root=1-58d534a5-1e7cffe644b086304dce7a1e", 434 | "HTTP_X_AMZ_CF_ID": "jx0Bvz9rm--Mz3wAj4i46FdOQQK3RHF4H0moJjBsQ==", 435 | "HTTP_X_FORWARDED_FOR": "76.20.166.147, 205.251.218.72", 436 | "HTTP_X_FORWARDED_PORT": "443", 437 | "HTTP_X_FORWARDED_PROTO": "https", 438 | "PATH_INFO": "/some/path", 439 | "QUERY_STRING": urlencode(event_v1["queryStringParameters"], doseq=True), 440 | "REMOTE_ADDR": "76.20.166.147", 441 | "REMOTE_USER": "wile_e_coyote", 442 | "REQUEST_METHOD": "GET", 443 | "SCRIPT_NAME": "", 444 | "SERVER_NAME": "custom.domain.com", 445 | "SERVER_PORT": "443", 446 | "SERVER_PROTOCOL": "HTTP/1.1", 447 | "wsgi.errors": wsgi_handler.wsgi_app.last_environ["wsgi.errors"], 448 | "wsgi.input": wsgi_handler.wsgi_app.last_environ["wsgi.input"], 449 | "wsgi.multiprocess": False, 450 | "wsgi.multithread": False, 451 | "wsgi.run_once": False, 452 | "wsgi.url_scheme": "https", 453 | "wsgi.version": (1, 0), 454 | "serverless.authorizer": {"principalId": "wile_e_coyote"}, 455 | "serverless.context": {}, 456 | "serverless.event": event_v1, 457 | } 458 | 459 | 460 | def test_handler_api_gateway_base_path( 461 | mock_wsgi_app_file, mock_app, event_v1, wsgi_handler 462 | ): 463 | event_v1["headers"]["Host"] = "custom.domain.com" 464 | event_v1["path"] = "/prod/some/path" 465 | try: 466 | os.environ["API_GATEWAY_BASE_PATH"] = "prod" 467 | wsgi_handler.handler(event_v1, {}) 468 | finally: 469 | del os.environ["API_GATEWAY_BASE_PATH"] 470 | 471 | assert wsgi_handler.wsgi_app.last_environ == { 472 | "CONTENT_LENGTH": "0", 473 | "CONTENT_TYPE": "", 474 | "HTTP_ACCEPT": "*/*", 475 | "HTTP_ACCEPT_ENCODING": "gzip, deflate", 476 | "HTTP_CACHE_CONTROL": "no-cache", 477 | "HTTP_CLOUDFRONT_FORWARDED_PROTO": "https", 478 | "HTTP_CLOUDFRONT_IS_DESKTOP_VIEWER": "true", 479 | "HTTP_CLOUDFRONT_IS_MOBILE_VIEWER": "false", 480 | "HTTP_CLOUDFRONT_IS_SMARTTV_VIEWER": "false", 481 | "HTTP_CLOUDFRONT_IS_TABLET_VIEWER": "false", 482 | "HTTP_CLOUDFRONT_VIEWER_COUNTRY": "DK", 483 | "HTTP_COOKIE": "CUSTOMER=WILE_E_COYOTE; PART_NUMBER=ROCKET_LAUNCHER_0001", 484 | "HTTP_HOST": "custom.domain.com", 485 | "HTTP_POSTMAN_TOKEN": "778a706e-d6b0-48d5-94dd-9e98c22f12fe", 486 | "HTTP_USER_AGENT": "PostmanRuntime/3.0.11-hotfix.2", 487 | "HTTP_VIA": "1.1 b8fa.cloudfront.net (CloudFront)", 488 | "HTTP_X_AMZN_TRACE_ID": "Root=1-58d534a5-1e7cffe644b086304dce7a1e", 489 | "HTTP_X_AMZ_CF_ID": "jx0Bvz9rm--Mz3wAj4i46FdOQQK3RHF4H0moJjBsQ==", 490 | "HTTP_X_FORWARDED_FOR": "76.20.166.147, 205.251.218.72", 491 | "HTTP_X_FORWARDED_PORT": "443", 492 | "HTTP_X_FORWARDED_PROTO": "https", 493 | "PATH_INFO": "/some/path", 494 | "QUERY_STRING": urlencode(event_v1["queryStringParameters"]), 495 | "REMOTE_ADDR": "76.20.166.147", 496 | "REMOTE_USER": "wile_e_coyote", 497 | "REQUEST_METHOD": "GET", 498 | "SCRIPT_NAME": "/prod", 499 | "SERVER_NAME": "custom.domain.com", 500 | "SERVER_PORT": "443", 501 | "SERVER_PROTOCOL": "HTTP/1.1", 502 | "wsgi.errors": wsgi_handler.wsgi_app.last_environ["wsgi.errors"], 503 | "wsgi.input": wsgi_handler.wsgi_app.last_environ["wsgi.input"], 504 | "wsgi.multiprocess": False, 505 | "wsgi.multithread": False, 506 | "wsgi.run_once": False, 507 | "wsgi.url_scheme": "https", 508 | "wsgi.version": (1, 0), 509 | "serverless.authorizer": {"principalId": "wile_e_coyote"}, 510 | "serverless.context": {}, 511 | "serverless.event": event_v1, 512 | } 513 | 514 | 515 | def test_handler_strip_stage_path(mock_wsgi_app_file, mock_app, event_v1, wsgi_handler): 516 | try: 517 | os.environ["STRIP_STAGE_PATH"] = "True" 518 | wsgi_handler.handler(event_v1, {}) 519 | finally: 520 | del os.environ["STRIP_STAGE_PATH"] 521 | 522 | assert wsgi_handler.wsgi_app.last_environ["SCRIPT_NAME"] == "" 523 | 524 | 525 | def test_handler_base64(mock_wsgi_app_file, mock_app, event_v1, wsgi_handler): 526 | wsgi_handler.wsgi_app.cookie_count = 1 527 | wsgi_handler.wsgi_app.response_mimetype = "image/jpeg" 528 | response = wsgi_handler.handler(event_v1, {}) 529 | 530 | assert response == { 531 | "body": "SGVsbG8gV29ybGQg4piDIQ==", 532 | "headers": { 533 | "Set-Cookie": "CUSTOMER=WILE_E_COYOTE; Path=/", 534 | "Content-Length": "16", 535 | "Content-Type": "image/jpeg", 536 | }, 537 | "statusCode": 200, 538 | "isBase64Encoded": True, 539 | } 540 | 541 | 542 | def test_handler_plain(mock_wsgi_app_file, mock_app, event_v1, wsgi_handler): 543 | wsgi_handler.wsgi_app.cookie_count = 1 544 | 545 | plain_mimetypes = [ 546 | "application/vnd.api+json", 547 | "application/javascript; charset=utf-8", 548 | "image/svg+xml; charset=utf-8", 549 | ] 550 | 551 | for mimetype in plain_mimetypes: 552 | wsgi_handler.wsgi_app.response_mimetype = mimetype 553 | response = wsgi_handler.handler(event_v1, {}) 554 | 555 | assert response == { 556 | "body": "Hello World ☃!", 557 | "headers": { 558 | "Set-Cookie": "CUSTOMER=WILE_E_COYOTE; Path=/", 559 | "Content-Length": "16", 560 | "Content-Type": mimetype, 561 | }, 562 | "statusCode": 200, 563 | "isBase64Encoded": False, 564 | } 565 | 566 | 567 | def test_handler_base64_request(mock_wsgi_app_file, mock_app, event_v1, wsgi_handler): 568 | event_v1["body"] = "SGVsbG8gd29ybGQ=" 569 | event_v1["headers"]["Content-Type"] = "text/plain" 570 | event_v1["isBase64Encoded"] = True 571 | event_v1["httpMethod"] = "PUT" 572 | 573 | wsgi_handler.handler(event_v1, {}) 574 | 575 | environ = wsgi_handler.wsgi_app.last_environ 576 | 577 | assert environ["CONTENT_TYPE"] == "text/plain" 578 | assert environ["CONTENT_LENGTH"] == "11" 579 | assert environ["REQUEST_METHOD"] == "PUT" 580 | assert environ["wsgi.input"].getvalue().decode() == "Hello world" 581 | 582 | 583 | def test_non_package_subdir_app(mock_subdir_wsgi_app_file, mock_app, wsgi_handler): 584 | assert wsgi_handler.wsgi_app.module == "app" 585 | 586 | 587 | def test_handler_binary_request_body( 588 | mock_wsgi_app_file, mock_app, event_v1, wsgi_handler 589 | ): 590 | event_v1["body"] = ( 591 | "LS0tLS0tV2ViS2l0Rm9ybUJvdW5kYXJ5VTRDZE5CRWVLQWxIaGRRcQ0KQ29udGVu" 592 | "dC1EaXNwb3NpdGlvbjogZm9ybS1kYXRhOyBuYW1lPSJ3YXQiDQoNCmhleW9vb3Bw" 593 | "cHBwDQotLS0tLS1XZWJLaXRGb3JtQm91bmRhcnlVNENkTkJFZUtBbEhoZFFxDQpD" 594 | "b250ZW50LURpc3Bvc2l0aW9uOiBmb3JtLWRhdGE7IG5hbWU9ImZpbGVUb1VwbG9h" 595 | "ZCI7IGZpbGVuYW1lPSJGRjREMDAtMC44LnBuZyINCkNvbnRlbnQtVHlwZTogaW1h" 596 | "Z2UvcG5nDQoNColQTkcNChoKAAAADUlIRFIAAAABAAAAAQEDAAAAJdtWygAAAANQ" 597 | "TFRF/00AXDU4fwAAAAF0Uk5TzNI0Vv0AAAAKSURBVHicY2IAAAAGAAM2N3yoAAAA" 598 | "AElFTkSuQmCCDQotLS0tLS1XZWJLaXRGb3JtQm91bmRhcnlVNENkTkJFZUtBbEho" 599 | "ZFFxDQpDb250ZW50LURpc3Bvc2l0aW9uOiBmb3JtLWRhdGE7IG5hbWU9InN1Ym1p" 600 | "dCINCg0KVXBsb2FkIEltYWdlDQotLS0tLS1XZWJLaXRGb3JtQm91bmRhcnlVNENk" 601 | "TkJFZUtBbEhoZFFxLS0NCg==" 602 | ) 603 | event_v1["headers"][ 604 | "Content-Type" 605 | ] = "multipart/form-data; boundary=----WebKitFormBoundaryU4CdNBEeKAlHhdQq" 606 | event_v1["isBase64Encoded"] = True 607 | event_v1["httpMethod"] = "POST" 608 | 609 | wsgi_handler.handler(event_v1, {}) 610 | 611 | environ = wsgi_handler.wsgi_app.last_environ 612 | 613 | assert environ["CONTENT_LENGTH"] == "496" 614 | assert Request(environ).form["submit"] == "Upload Image" 615 | 616 | 617 | def test_handler_request_body_undecodable_with_latin1( 618 | mock_wsgi_app_file, mock_app, event_v1, wsgi_handler 619 | ): 620 | event_v1["body"] = ( 621 | "------WebKitFormBoundary3vA72kRLuq9D3NdL\r\n" 622 | u'Content-Disposition: form-data; name="text"\r\n\r\n' 623 | "テスト 테스트 测试\r\n" 624 | "------WebKitFormBoundary3vA72kRLuq9D3NdL--" 625 | ) 626 | event_v1["headers"][ 627 | "Content-Type" 628 | ] = "multipart/form-data; boundary=----WebKitFormBoundary3vA72kRLuq9D3NdL" 629 | event_v1["httpMethod"] = "POST" 630 | 631 | wsgi_handler.handler(event_v1, {}) 632 | 633 | environ = wsgi_handler.wsgi_app.last_environ 634 | assert Request(environ).form["text"] == "テスト 테스트 测试" 635 | 636 | 637 | def test_handler_custom_text_mime_types( 638 | mock_text_mime_wsgi_app_file, mock_app, event_v1, wsgi_handler 639 | ): 640 | wsgi_handler.wsgi_app.cookie_count = 1 641 | wsgi_handler.wsgi_app.response_mimetype = "application/custom+json" 642 | response = wsgi_handler.handler(event_v1, {}) 643 | 644 | assert response == { 645 | "body": "Hello World ☃!", 646 | "headers": { 647 | "Set-Cookie": "CUSTOMER=WILE_E_COYOTE; Path=/", 648 | "Content-Length": "16", 649 | "Content-Type": "application/custom+json", 650 | }, 651 | "statusCode": 200, 652 | "isBase64Encoded": False, 653 | } 654 | 655 | 656 | def test_handler_alb(mock_wsgi_app_file, mock_app, wsgi_handler, elb_event): 657 | response = wsgi_handler.handler(elb_event, {}) 658 | 659 | assert response == { 660 | "body": "Hello World ☃!", 661 | "headers": { 662 | "set-cookie": "CUSTOMER=WILE_E_COYOTE; Path=/", 663 | "Content-Length": "16", 664 | "Content-Type": "text/plain; charset=utf-8", 665 | "sEt-cookie": "LOT_NUMBER=42; Path=/", 666 | "Set-cookie": "PART_NUMBER=ROCKET_LAUNCHER_0002; Path=/", 667 | }, 668 | "statusDescription": "200 OK", 669 | "statusCode": 200, 670 | "isBase64Encoded": False, 671 | } 672 | 673 | 674 | def test_alb_query_params(mock_wsgi_app_file, mock_app, wsgi_handler, elb_event): 675 | elb_event["queryStringParameters"] = {"test": "test%20test"} 676 | response = wsgi_handler.handler(elb_event, {}) 677 | query_string = wsgi_handler.wsgi_app.last_environ["QUERY_STRING"] 678 | assert query_string == "test=test+test" 679 | 680 | assert response == { 681 | "body": "Hello World ☃!", 682 | "headers": { 683 | "set-cookie": "CUSTOMER=WILE_E_COYOTE; Path=/", 684 | "Content-Length": "16", 685 | "Content-Type": "text/plain; charset=utf-8", 686 | "sEt-cookie": "LOT_NUMBER=42; Path=/", 687 | "Set-cookie": "PART_NUMBER=ROCKET_LAUNCHER_0002; Path=/", 688 | }, 689 | "statusDescription": "200 OK", 690 | "statusCode": 200, 691 | "isBase64Encoded": False, 692 | } 693 | 694 | 695 | def test_alb_multi_query_params(mock_wsgi_app_file, mock_app, wsgi_handler, elb_event): 696 | del elb_event["queryStringParameters"] 697 | elb_event["multiValueQueryStringParameters"] = { 698 | "%E6%B8%AC%E8%A9%A6": ["%E3%83%86%E3%82%B9%E3%83%88", "test"], 699 | "test": "test%20test", 700 | } 701 | response = wsgi_handler.handler(elb_event, {}) 702 | query_string = wsgi_handler.wsgi_app.last_environ["QUERY_STRING"] 703 | assert query_string == urlencode( 704 | {"測試": ["テスト", "test"], "test": "test test"}, doseq=True) 705 | 706 | assert response == { 707 | "body": "Hello World ☃!", 708 | "headers": { 709 | "set-cookie": "CUSTOMER=WILE_E_COYOTE; Path=/", 710 | "Content-Length": "16", 711 | "Content-Type": "text/plain; charset=utf-8", 712 | "sEt-cookie": "LOT_NUMBER=42; Path=/", 713 | "Set-cookie": "PART_NUMBER=ROCKET_LAUNCHER_0002; Path=/", 714 | }, 715 | "statusDescription": "200 OK", 716 | "statusCode": 200, 717 | "isBase64Encoded": False, 718 | } 719 | 720 | 721 | def test_command_exec(mock_wsgi_app_file, mock_app, wsgi_handler): 722 | response = wsgi_handler.handler( 723 | {"_serverless-wsgi": {"command": "exec", "data": "print(1+4)"}}, {} 724 | ) 725 | 726 | assert response[0] == 0 727 | assert response[1] == "5\n" 728 | 729 | response = wsgi_handler.handler( 730 | {"_serverless-wsgi": {"command": "exec", "data": "invalid code"}}, {} 731 | ) 732 | 733 | assert response[0] == 1 734 | assert "Traceback (most recent call last):" in response[1] 735 | assert "SyntaxError: invalid syntax" in response[1] 736 | 737 | 738 | def test_command_command(mock_wsgi_app_file, mock_app, wsgi_handler): 739 | response = wsgi_handler.handler( 740 | {"_serverless-wsgi": {"command": "command", "data": 'echo "hello world"'}}, {} 741 | ) 742 | 743 | assert response[0] == 0 744 | assert response[1] == "hello world\n" 745 | 746 | response = wsgi_handler.handler( 747 | { 748 | "_serverless-wsgi": { 749 | "command": "command", 750 | "data": "ls non-existing-filename", 751 | } 752 | }, 753 | {}, 754 | ) 755 | 756 | assert response[0] > 0 757 | assert "No such file or directory" in response[1] 758 | 759 | 760 | def test_command_manage(mock_wsgi_app_file, mock_app, wsgi_handler): 761 | class MockObject: 762 | pass 763 | 764 | class MockDjango: 765 | def call_command(*args): 766 | print("Called with: {}".format(", ".join(args[1:]))) 767 | 768 | sys.modules["django"] = MockObject() 769 | sys.modules["django.core"] = MockObject() 770 | sys.modules["django.core"].management = MockDjango() 771 | 772 | response = wsgi_handler.handler( 773 | {"_serverless-wsgi": {"command": "manage", "data": "check --list-tags"}}, {} 774 | ) 775 | 776 | assert response[0] == 0 777 | assert response[1] == "Called with: check, --list-tags\n" 778 | 779 | 780 | def test_command_flask(mock_wsgi_app_file, mock_app, wsgi_handler): 781 | class MockObject: 782 | def __init__(self, **kwargs): 783 | for k, v in kwargs.items(): 784 | self.__dict__[k] = v 785 | 786 | class MockFlaskGroup: 787 | def __init__(self, create_app): 788 | assert create_app() == mock_app 789 | 790 | def main(ctx, args, standalone_mode): 791 | assert not standalone_mode 792 | print("Called with: {}".format(", ".join(args))) 793 | 794 | sys.modules["flask"] = MockObject() 795 | sys.modules["flask.cli"] = MockObject(FlaskGroup=MockFlaskGroup) 796 | 797 | response = wsgi_handler.handler( 798 | {"_serverless-wsgi": {"command": "flask", "data": "custom command"}}, {} 799 | ) 800 | 801 | assert response[0] == 0 802 | assert response[1] == "Called with: custom, command\n" 803 | 804 | 805 | def test_command_unknown(mock_wsgi_app_file, mock_app, wsgi_handler): 806 | response = wsgi_handler.handler( 807 | {"_serverless-wsgi": {"command": "unknown", "data": 'echo "hello world"'}}, {} 808 | ) 809 | 810 | assert response[0] == 1 811 | assert "Traceback (most recent call last):" in response[1] 812 | assert "Exception: Unknown command: unknown" in response[1] 813 | 814 | 815 | def test_app_import_error(mock_wsgi_app_file, mock_app_with_import_error, event_v1, wsgi_handler): 816 | response = wsgi_handler.handler(event_v1, {}) 817 | assert response == { 818 | "statusCode": 500, 819 | "body": "\n\n500 Internal Server Error\n

Internal Server Error

\n

Unable to import app: app.app

\n", 820 | "headers": { 821 | "Content-Type": "text/html; charset=utf-8", 822 | "Content-Length": "140" 823 | }, 824 | "isBase64Encoded": False 825 | } 826 | 827 | 828 | def test_handler_with_encoded_characters_in_path( 829 | mock_wsgi_app_file, mock_app, event_v1, capsys, wsgi_handler 830 | ): 831 | event_v1["path"] = "/city/new%20york" 832 | wsgi_handler.handler(event_v1, {"memory_limit_in_mb": "128"}) 833 | assert wsgi_handler.wsgi_app.last_environ["PATH_INFO"] == "/city/new york" 834 | 835 | 836 | @pytest.fixture 837 | def event_v2(): 838 | return { 839 | "version": "2.0", 840 | "routeKey": "GET /some/path", 841 | "rawPath": "/some/path", 842 | "rawQueryString": "param1=value1¶m2=value2¶m2=value3", 843 | "cookies": ["CUSTOMER=WILE_E_COYOTE", "PART_NUMBER=ROCKET_LAUNCHER_0001"], 844 | "headers": { 845 | "Accept": "*/*", 846 | "Accept-Encoding": "gzip, deflate", 847 | "CloudFront-Forwarded-Proto": "https", 848 | "CloudFront-Is-Desktop-Viewer": "true", 849 | "CloudFront-Is-Mobile-Viewer": "false", 850 | "CloudFront-Is-SmartTV-Viewer": "false", 851 | "CloudFront-Is-Tablet-Viewer": "false", 852 | "CloudFront-Viewer-Country": "DK", 853 | "Host": "3z6kd9fbb1.execute-api.us-east-1.amazonaws.com", 854 | "Postman-Token": "778a706e-d6b0-48d5-94dd-9e98c22f12fe", 855 | "User-Agent": "PostmanRuntime/3.0.11-hotfix.2", 856 | "Via": "1.1 b8fa.cloudfront.net (CloudFront)", 857 | "X-Amz-Cf-Id": "jx0Bvz9rm--Mz3wAj4i46FdOQQK3RHF4H0moJjBsQ==", 858 | "X-Amzn-Trace-Id": "Root=1-58d534a5-1e7cffe644b086304dce7a1e", 859 | "X-Forwarded-For": "76.20.166.147, 205.251.218.72", 860 | "X-Forwarded-Port": "443", 861 | "X-Forwarded-Proto": "https", 862 | "cache-control": "no-cache", 863 | }, 864 | "queryStringParameters": {"param1": "value1", "param2": "value2,value3"}, 865 | "requestContext": { 866 | "accountId": "16794", 867 | "apiId": "3z6kd9fbb1", 868 | "authorizer": {"principalId": "wile_e_coyote"}, 869 | "domainName": "id.execute-api.us-east-1.amazonaws.com", 870 | "domainPrefix": "id", 871 | "http": { 872 | "method": "GET", 873 | "path": "/some/path", 874 | "protocol": "HTTP/1.1", 875 | "sourceIp": "76.20.166.147", 876 | "userAgent": "agent", 877 | }, 878 | "requestId": "ad2db740-10a2-11e7-8ced-35048084babb", 879 | "stage": "dev", 880 | "routeKey": "$default", 881 | "time": "12/Mar/2020:19:03:58 +0000", 882 | "timeEpoch": 1583348638390, 883 | }, 884 | "pathParameters": {"proxy": "some/path"}, 885 | "isBase64Encoded": False, 886 | "stageVariables": None, 887 | } 888 | 889 | 890 | def test_handler_v2(mock_wsgi_app_file, mock_app, event_v2, capsys, wsgi_handler): 891 | response = wsgi_handler.handler(event_v2, {"memory_limit_in_mb": "128"}) 892 | 893 | assert response == { 894 | "body": "Hello World ☃!", 895 | "headers": { 896 | "set-cookie": "CUSTOMER=WILE_E_COYOTE; Path=/", 897 | "Content-Length": "16", 898 | "Content-Type": "text/plain; charset=utf-8", 899 | "sEt-cookie": "LOT_NUMBER=42; Path=/", 900 | "Set-cookie": "PART_NUMBER=ROCKET_LAUNCHER_0002; Path=/", 901 | }, 902 | "statusCode": 200, 903 | "isBase64Encoded": False, 904 | } 905 | 906 | assert wsgi_handler.wsgi_app.last_environ == { 907 | "CONTENT_LENGTH": "0", 908 | "CONTENT_TYPE": "", 909 | "HTTP_ACCEPT": "*/*", 910 | "HTTP_ACCEPT_ENCODING": "gzip, deflate", 911 | "HTTP_CACHE_CONTROL": "no-cache", 912 | "HTTP_CLOUDFRONT_FORWARDED_PROTO": "https", 913 | "HTTP_CLOUDFRONT_IS_DESKTOP_VIEWER": "true", 914 | "HTTP_CLOUDFRONT_IS_MOBILE_VIEWER": "false", 915 | "HTTP_CLOUDFRONT_IS_SMARTTV_VIEWER": "false", 916 | "HTTP_CLOUDFRONT_IS_TABLET_VIEWER": "false", 917 | "HTTP_CLOUDFRONT_VIEWER_COUNTRY": "DK", 918 | "HTTP_COOKIE": "CUSTOMER=WILE_E_COYOTE; PART_NUMBER=ROCKET_LAUNCHER_0001", 919 | "HTTP_HOST": "3z6kd9fbb1.execute-api.us-east-1.amazonaws.com", 920 | "HTTP_POSTMAN_TOKEN": "778a706e-d6b0-48d5-94dd-9e98c22f12fe", 921 | "HTTP_USER_AGENT": "PostmanRuntime/3.0.11-hotfix.2", 922 | "HTTP_VIA": "1.1 b8fa.cloudfront.net (CloudFront)", 923 | "HTTP_X_AMZN_TRACE_ID": "Root=1-58d534a5-1e7cffe644b086304dce7a1e", 924 | "HTTP_X_AMZ_CF_ID": "jx0Bvz9rm--Mz3wAj4i46FdOQQK3RHF4H0moJjBsQ==", 925 | "HTTP_X_FORWARDED_FOR": "76.20.166.147, 205.251.218.72", 926 | "HTTP_X_FORWARDED_PORT": "443", 927 | "HTTP_X_FORWARDED_PROTO": "https", 928 | "PATH_INFO": "/some/path", 929 | "QUERY_STRING": "param1=value1¶m2=value2¶m2=value3", 930 | "REMOTE_ADDR": "76.20.166.147", 931 | "REMOTE_USER": "wile_e_coyote", 932 | "REQUEST_METHOD": "GET", 933 | "SCRIPT_NAME": "/dev", 934 | "SERVER_NAME": "3z6kd9fbb1.execute-api.us-east-1.amazonaws.com", 935 | "SERVER_PORT": "443", 936 | "SERVER_PROTOCOL": "HTTP/1.1", 937 | "wsgi.errors": wsgi_handler.wsgi_app.last_environ["wsgi.errors"], 938 | "wsgi.input": wsgi_handler.wsgi_app.last_environ["wsgi.input"], 939 | "wsgi.multiprocess": False, 940 | "wsgi.multithread": False, 941 | "wsgi.run_once": False, 942 | "wsgi.url_scheme": "https", 943 | "wsgi.version": (1, 0), 944 | "serverless.authorizer": {"principalId": "wile_e_coyote"}, 945 | "serverless.context": {"memory_limit_in_mb": "128"}, 946 | "serverless.event": event_v2, 947 | } 948 | 949 | out, err = capsys.readouterr() 950 | assert out == "" 951 | assert err == "application debug #1\n" 952 | 953 | 954 | def test_handler_with_encoded_characters_in_path_v2( 955 | mock_wsgi_app_file, mock_app, event_v2, capsys, wsgi_handler 956 | ): 957 | event_v2["rawPath"] = "/city/new%20york" 958 | wsgi_handler.handler(event_v2, {"memory_limit_in_mb": "128"}) 959 | assert wsgi_handler.wsgi_app.last_environ["PATH_INFO"] == "/city/new york" 960 | 961 | 962 | @pytest.fixture 963 | def event_lambda_integration(): 964 | return { 965 | "body": {}, 966 | "method": "GET", 967 | "principalId": "testuser", 968 | "stage": "dev", 969 | "cognitoPoolClaims": {"sub": ""}, 970 | "enhancedAuthContext": { 971 | "principalId": "testuser", 972 | "integrationLatency": "1031", 973 | "contextTest": "123", 974 | }, 975 | "headers": { 976 | "Accept": "*/*", 977 | "Authorization": "Bearer f14a720d62e1d1295d9", 978 | "CloudFront-Forwarded-Proto": "https", 979 | "CloudFront-Is-Desktop-Viewer": "true", 980 | "CloudFront-Is-Mobile-Viewer": "false", 981 | "CloudFront-Is-SmartTV-Viewer": "false", 982 | "CloudFront-Is-Tablet-Viewer": "false", 983 | "CloudFront-Viewer-Country": "FI", 984 | "Host": "k3k8rkx1mf.execute-api.us-east-1.amazonaws.com", 985 | "User-Agent": "curl/7.68.0", 986 | "Via": "2.0 3bf180720d62e0d1295d99086d103efb.cloudfront.net (CloudFront)", 987 | "X-Amz-Cf-Id": "9Z6K736EDx_vlsij1PA-ZVxIPPi-vAIMaLNOvJ2FrbpvGMisAISY8Q==", 988 | "X-Amzn-Trace-Id": "Root=1-5055b7d3-751afb497f81bab2759b6e7b", 989 | "X-Forwarded-For": "83.23.10.243, 130.166.149.164", 990 | "X-Forwarded-Port": "443", 991 | "X-Forwarded-Proto": "https", 992 | }, 993 | "query": {"q": "test"}, 994 | "path": {"p": "path2"}, 995 | "identity": { 996 | "cognitoIdentityPoolId": "", 997 | "accountId": "", 998 | "cognitoIdentityId": "", 999 | "caller": "", 1000 | "sourceIp": "83.23.100.243", 1001 | "principalOrgId": "", 1002 | "accessKey": "", 1003 | "cognitoAuthenticationType": "", 1004 | "cognitoAuthenticationProvider": "", 1005 | "userArn": "", 1006 | "userAgent": "curl/7.68.0", 1007 | "user": "", 1008 | }, 1009 | "stageVariables": {}, 1010 | "requestPath": "/some/{p}", 1011 | } 1012 | 1013 | 1014 | def test_handler_lambda( 1015 | mock_wsgi_app_file, mock_app, event_lambda_integration, capsys, wsgi_handler 1016 | ): 1017 | response = wsgi_handler.handler( 1018 | event_lambda_integration, {"memory_limit_in_mb": "128"} 1019 | ) 1020 | 1021 | assert response == { 1022 | "body": "Hello World ☃!", 1023 | "headers": { 1024 | "set-cookie": "CUSTOMER=WILE_E_COYOTE; Path=/", 1025 | "Content-Length": "16", 1026 | "Content-Type": "text/plain; charset=utf-8", 1027 | "sEt-cookie": "LOT_NUMBER=42; Path=/", 1028 | "Set-cookie": "PART_NUMBER=ROCKET_LAUNCHER_0002; Path=/", 1029 | }, 1030 | "statusCode": 200, 1031 | "isBase64Encoded": False, 1032 | } 1033 | 1034 | assert wsgi_handler.wsgi_app.last_environ == { 1035 | "CONTENT_LENGTH": "0", 1036 | "CONTENT_TYPE": "", 1037 | "HTTP_ACCEPT": "*/*", 1038 | "HTTP_AUTHORIZATION": "Bearer f14a720d62e1d1295d9", 1039 | "HTTP_CLOUDFRONT_FORWARDED_PROTO": "https", 1040 | "HTTP_CLOUDFRONT_IS_DESKTOP_VIEWER": "true", 1041 | "HTTP_CLOUDFRONT_IS_MOBILE_VIEWER": "false", 1042 | "HTTP_CLOUDFRONT_IS_SMARTTV_VIEWER": "false", 1043 | "HTTP_CLOUDFRONT_IS_TABLET_VIEWER": "false", 1044 | "HTTP_CLOUDFRONT_VIEWER_COUNTRY": "FI", 1045 | "HTTP_HOST": "k3k8rkx1mf.execute-api.us-east-1.amazonaws.com", 1046 | "HTTP_USER_AGENT": "curl/7.68.0", 1047 | "HTTP_VIA": "2.0 3bf180720d62e0d1295d99086d103efb.cloudfront.net (CloudFront)", 1048 | "HTTP_X_AMZN_TRACE_ID": "Root=1-5055b7d3-751afb497f81bab2759b6e7b", 1049 | "HTTP_X_AMZ_CF_ID": "9Z6K736EDx_vlsij1PA-ZVxIPPi-vAIMaLNOvJ2FrbpvGMisAISY8Q==", 1050 | "HTTP_X_FORWARDED_FOR": "83.23.10.243, 130.166.149.164", 1051 | "HTTP_X_FORWARDED_PORT": "443", 1052 | "HTTP_X_FORWARDED_PROTO": "https", 1053 | "PATH_INFO": "/some/path2", 1054 | "QUERY_STRING": "q=test", 1055 | "REMOTE_ADDR": "83.23.100.243", 1056 | "REMOTE_USER": "testuser", 1057 | "REQUEST_METHOD": "GET", 1058 | "SCRIPT_NAME": "/dev", 1059 | "SERVER_NAME": "k3k8rkx1mf.execute-api.us-east-1.amazonaws.com", 1060 | "SERVER_PORT": "443", 1061 | "SERVER_PROTOCOL": "HTTP/1.1", 1062 | "wsgi.errors": wsgi_handler.wsgi_app.last_environ["wsgi.errors"], 1063 | "wsgi.input": wsgi_handler.wsgi_app.last_environ["wsgi.input"], 1064 | "wsgi.multiprocess": False, 1065 | "wsgi.multithread": False, 1066 | "wsgi.run_once": False, 1067 | "wsgi.url_scheme": "https", 1068 | "wsgi.version": (1, 0), 1069 | "serverless.authorizer": { 1070 | "principalId": "testuser", 1071 | "integrationLatency": "1031", 1072 | "contextTest": "123", 1073 | }, 1074 | "serverless.context": {"memory_limit_in_mb": "128"}, 1075 | "serverless.event": event_lambda_integration, 1076 | } 1077 | 1078 | out, err = capsys.readouterr() 1079 | assert out == "" 1080 | assert err == "application debug #1\n" 1081 | 1082 | 1083 | def test_handler_lambda_error( 1084 | mock_wsgi_app_file, mock_app, event_lambda_integration, capsys, wsgi_handler 1085 | ): 1086 | mock_app.status_code = 400 1087 | with pytest.raises(Exception, match='"statusCode": 400'): 1088 | wsgi_handler.handler(event_lambda_integration, { 1089 | "memory_limit_in_mb": "128"}) 1090 | --------------------------------------------------------------------------------