├── .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 | [](https://nodei.co/npm/serverless-wsgi/)
6 |
7 | [](http://www.serverless.com)
8 | [](https://github.com/logandk/serverless-wsgi/actions/workflows/tests.yaml)
9 | [](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 | ❯ ❯ s l s wsgi serve ❯ sl s wsgi serve ❯ sls wsgi serve ❯ sls w sgi serve ❯ sls w s gi serve ❯ sls ws g i serve ❯ sls wsg i serve ❯ sls wsgi serve ❯ sls wsgi s erve ❯ sls wsgi s e rve ❯ sls wsgi se r ve ❯ sls wsgi ser v e ❯ sls wsgi serv e ❯ 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 \nInternal Server Error \nUnable 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 |
--------------------------------------------------------------------------------