├── .circleci └── config.yml ├── .gitignore ├── .pre-commit-config.yaml ├── AUTHORS.txt ├── CHANGES.txt ├── LICENSE.txt ├── MANIFEST.in ├── README.md ├── codecov.yml ├── example ├── README.md ├── cli.py ├── handler.py └── rpix.png ├── lambda_proxy ├── __init__.py ├── proxy.py └── templates.py ├── setup.py ├── tests ├── fixtures │ ├── openapi.json │ ├── openapi_apigw.json │ └── openapi_custom.json └── test_proxy.py └── tox.ini /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | common: &common 4 | working_directory: ~/lambda-proxy 5 | steps: 6 | - checkout 7 | - run: 8 | name: install dependencies 9 | command: pip install tox codecov pre-commit --user 10 | - run: 11 | name: run tox 12 | command: ~/.local/bin/tox 13 | - run: 14 | name: run pre-commit 15 | command: | 16 | if [[ "$CIRCLE_JOB" == "python-3.7" ]]; then 17 | ~/.local/bin/pre-commit run --all-files 18 | fi 19 | - run: 20 | name: upload coverage report 21 | command: | 22 | if [[ "$UPLOAD_COVERAGE" == 1 ]]; then 23 | ~/.local/bin/coverage xml 24 | ~/.local/bin/codecov 25 | fi 26 | when: always 27 | 28 | jobs: 29 | "python-3.6": 30 | <<: *common 31 | docker: 32 | - image: circleci/python:3.6.5 33 | environment: 34 | - TOXENV=py36 35 | 36 | "python-3.7": 37 | <<: *common 38 | docker: 39 | - image: circleci/python:3.7.2 40 | environment: 41 | - TOXENV=py37 42 | - UPLOAD_COVERAGE=1 43 | 44 | deploy: 45 | docker: 46 | - image: circleci/python:3.7.2 47 | environment: 48 | - TOXENV=release 49 | working_directory: ~/lambda-proxy 50 | steps: 51 | - checkout 52 | - run: 53 | name: verify git tag vs. version 54 | command: | 55 | VERSION=$(python setup.py --version) 56 | if [ "$VERSION" = "$CIRCLE_TAG" ]; then exit 0; else exit 3; fi 57 | - run: 58 | name: install dependencies 59 | command: pip install tox --user 60 | - run: 61 | name: init .pypirc 62 | command: | 63 | echo -e "[pypi]" >> ~/.pypirc 64 | echo -e "username = $PYPI_USER" >> ~/.pypirc 65 | echo -e "password = $PYPI_PASSWORD" >> ~/.pypirc 66 | - run: 67 | name: run tox 68 | command: ~/.local/bin/tox 69 | 70 | 71 | workflows: 72 | version: 2 73 | test_and_deploy: 74 | jobs: 75 | - "python-3.6" 76 | - "python-3.7": 77 | filters: # required since `deploy` has tag filters AND requires `build` 78 | tags: 79 | only: /.*/ 80 | - deploy: 81 | requires: 82 | - "python-3.7" 83 | filters: 84 | tags: 85 | only: /^[0-9]+.*/ 86 | branches: 87 | ignore: /.*/ 88 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | 2 | repos: 3 | - 4 | repo: 'https://github.com/psf/black' 5 | rev: stable 6 | hooks: 7 | - id: black 8 | args: ['--safe'] 9 | language_version: python3.7 10 | - 11 | repo: 'https://github.com/pre-commit/pre-commit-hooks' 12 | rev: v2.4.0 13 | hooks: 14 | - id: flake8 15 | language_version: python3.7 16 | args: [ 17 | # E501 let black handle all line length decisions 18 | # W503 black conflicts with "line break before operator" rule 19 | # E203 black conflicts with "whitespace before ':'" rule 20 | '--ignore=E501,W503,E203'] 21 | - 22 | repo: 'https://github.com/chewse/pre-commit-mirrors-pydocstyle' 23 | # 2.1.1 24 | rev: 22d3ccf6cf91ffce3b16caa946c155778f0cb20f 25 | hooks: 26 | - id: pydocstyle 27 | language_version: python3.7 28 | args: [ 29 | # Check for docstring presence only 30 | '--select=D1', 31 | # Don't require docstrings for tests 32 | '--match=(?!test).*\.py'] 33 | 34 | - 35 | repo: 'https://github.com/pre-commit/mirrors-mypy' 36 | rev: v0.770 37 | hooks: 38 | - id: mypy 39 | language_version: python3.7 40 | args: [--no-strict-optional, --ignore-missing-imports] 41 | -------------------------------------------------------------------------------- /AUTHORS.txt: -------------------------------------------------------------------------------- 1 | Authors 2 | ------- 3 | 4 | * Vincent Sarago 5 | 6 | See also https://github.com/vincentsarago/lambda-proxy/graphs/contributors. 7 | -------------------------------------------------------------------------------- /CHANGES.txt: -------------------------------------------------------------------------------- 1 | Changes 2 | ======= 3 | 5.2.1 (2020-05-04) 4 | - Fix bad api prefix when using new $default HTTP api stage 5 | 6 | 5.2.0 (2020-04-09) 7 | - change `api.routes` to list to allow path per methods 8 | (e.g you can now add route with the same path but with different methods). 9 | - add `@app.get` `@app.post` route wrapper to add routes 10 | - added mypy integration 11 | 12 | 5.1.1 (2020-04-03) 13 | - Adapt for httpAPI (#39, author kylebarron) 14 | 15 | 5.1.0 (2020-03-23) 16 | - replace ttl by cache-control (#32) 17 | - allow integer status code (#35) 18 | 19 | 5.0.2 (2019-11-20) 20 | - fix base64Encoded for POST requests 21 | 22 | 5.0.1 (2019-10-11) 23 | - fix bad encoding in README.md (#23) 24 | 25 | 5.0.0 (2019-09-27) 26 | **breacking changes** 27 | - decode base64 encoded body on POST requests 28 | - add Cache-Control (ttl) only on for 200 responses 29 | 30 | 4.1.4 (2019-08-19) 31 | - add `host` property to retrieve api host URL. 32 | 33 | 4.1.3 (2019-08-01) 34 | - Better edge cases handling for api gateway + prefix + custom domain path (#19) 35 | 36 | 4.1.2 (2019-08-01) 37 | - Fix bad regex use (#18) 38 | 39 | 4.1.1 (2019-08-01) 40 | - support path mapping in API Gateway custom domain (#16) 41 | 42 | 4.1.0 (2019-06-20) 43 | - add regex route definition 44 | - update regex parsing 45 | - rename private functions 46 | - update docs 47 | 48 | 4.0.0 (2019-06-01) 49 | - add OpenAPI documentation methods 50 | - add default documentation routes ("/openapi.json", "/docs", "/redoc") 51 | - add tag and description in route 52 | - add default tags for documentation routes 53 | - better regex 54 | - add code annotations 55 | 56 | **breacking change** 57 | - replace `app_name` by `name` 58 | - python 3 support only 59 | 60 | 3.0.1 (2019-04-24) 61 | - add TLL (max-age) in response header (#11) 62 | - add more binary media type 63 | 64 | 3.0.0 (2019-03-05) 65 | - fix regular expression 66 | 67 | **breacking change** 68 | - forward function inputs as `named arguments` instead of positional arguments. 69 | 70 | 2.0.4 (2019-02-26) 71 | - allow `context` and `event` passing to the function (#7) 72 | 73 | 2.0.3 (2019-02-21) 74 | - fix base64 encoding when compressing non-binary body 75 | 76 | 2.0.2 (2019-02-15) 77 | - fix bug when trying to compress txt 78 | 79 | 2.0.1 (2019-02-04) 80 | ------------------ 81 | - Fix bug when queryStringParameters or header are "None" 82 | 83 | 2.0.0 (2019-01-29) 84 | ------------------ 85 | - add per-route response base64 encoding 86 | - add per-route response compression (gzip, zlig, deflate) 87 | - make "GET" the default method for the route 88 | 89 | **breacking change** 90 | - python3 support only 91 | - change header status code to Integer in response 92 | 93 | 1.1.0 (2018-09-06) 94 | ------------------ 95 | - pass through 'queryStringParameters' as function kwargs 96 | 97 | 1.0.0 (2018-09-05) 98 | ------------------ 99 | - Global refactor 100 | - Ran Black, the uncompromising Python code formatter. 101 | - Full test suite 102 | - Better regex route parsing 103 | - Add logging option 104 | - Add POST 105 | 106 | 0.0.4 (2018-03-30) 107 | ---------------- 108 | - add `Access-Control-Allow-Credentials` in response headers. 109 | 110 | 0.0.1 (2017-11-26) 111 | ---------------- 112 | - Initial release. 113 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2017, RemotePixel.ca 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGES.txt AUTHORS.txt LICENSE.txt VERSION.txt README.md setup.py 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lambda-proxy 2 | 3 | [![Packaging status](https://badge.fury.io/py/lambda-proxy.svg)](https://badge.fury.io/py/lambda-proxy) 4 | [![CircleCI](https://circleci.com/gh/vincentsarago/lambda-proxy.svg?style=svg)](https://circleci.com/gh/vincentsarago/lambda-proxy) 5 | [![codecov](https://codecov.io/gh/vincentsarago/lambda-proxy/branch/master/graph/badge.svg)](https://codecov.io/gh/vincentsarago/lambda-proxy) 6 | 7 | A zero-requirement proxy linking AWS API Gateway `{proxy+}` requests and AWS Lambda. 8 | 9 | 10 | 11 | ## Install 12 | 13 | ```bash 14 | $ pip install -U pip 15 | $ pip install lambda-proxy 16 | ``` 17 | 18 | Or install from source: 19 | 20 | ```bash 21 | $ git clone https://github.com/vincentsarag/lambda-proxy.git 22 | $ cd lambda-proxy 23 | $ pip install -U pip 24 | $ pip install -e . 25 | ``` 26 | 27 | # Usage 28 | 29 | Lambda proxy is designed to work well with both API Gateway's REST API and the 30 | newer and cheaper HTTP API. If you have issues using with the HTTP API, please 31 | open an issue. 32 | 33 | With GET request 34 | 35 | ```python 36 | from lambda_proxy.proxy import API 37 | 38 | APP = API(name="app") 39 | 40 | @APP.route('/test/tests/', methods=['GET'], cors=True) 41 | def print_id(id): 42 | return ('OK', 'plain/text', id) 43 | ``` 44 | 45 | With POST request 46 | 47 | ```python 48 | from lambda_proxy.proxy import API 49 | 50 | APP = API(name="app") 51 | 52 | @APP.route('/test/tests/', methods=['POST'], cors=True) 53 | def print_id(id, body): 54 | return ('OK', 'plain/text', id) 55 | ``` 56 | 57 | **Note** 58 | 59 | Starting in version 5.2.0, users can now add route using `@APP.get` and `@APP.post` removing the need to add `methods=[**]` 60 | 61 | ## Binary body 62 | 63 | Starting from version 5.0.0, lambda-proxy will decode base64 encoded body on POST message. 64 | 65 | Pre 5.0.0 66 | ```python 67 | from lambda_proxy.proxy import API 68 | 69 | APP = API(name="app") 70 | 71 | @APP.route('/test', methods=['POST']e) 72 | def print_id(body): 73 | body = json.loads(base64.b64decode(body).decode()) 74 | ``` 75 | 76 | Post 5.0.0 77 | ```python 78 | from lambda_proxy.proxy import API 79 | 80 | APP = API(name="app") 81 | 82 | @APP.route('/test', methods=['POST']e) 83 | def print_id(body): 84 | body = json.loads(body) 85 | ``` 86 | 87 | # Routes 88 | 89 | Route schema is simmilar to the one used in [Flask](http://flask.pocoo.org/docs/1.0/api/#url-route-registrations) 90 | 91 | > Variable parts in the route can be specified with angular brackets `/user/`. By default a variable part in the URL accepts any string without a slash however a different converter can be specified as well by using ``. 92 | 93 | Converters: 94 | - `int`: integer 95 | - `string`: string 96 | - `float`: float number 97 | - `uuid`: UUID 98 | 99 | example: 100 | - `/app//` (`user` and `id` are variables) 101 | - `/app//` (`value` will be a string, while `num` will be a float) 102 | 103 | ## Regex 104 | You can also add regex parameters descriptions using special converter `regex()` 105 | 106 | example: 107 | ```python 108 | @APP.get("/app/") 109 | def print_user(regularuser): 110 | return ('OK', 'plain/text', f"regular {regularuser}") 111 | 112 | @APP.get("/app/") 113 | def print_user(capitaluser): 114 | return ('OK', 'plain/text', f"CAPITAL {capitaluser}") 115 | ``` 116 | 117 | #### Warning 118 | 119 | when using **regex()** you must use different variable names or the route might not show up in the documentation. 120 | 121 | ```python 122 | @APP.get("/app/") 123 | def print_user(user): 124 | return ('OK', 'plain/text', f"regular {user}") 125 | 126 | @APP.get("/app/") 127 | def print_user(user): 128 | return ('OK', 'plain/text', f"CAPITAL {user}") 129 | ``` 130 | This app will work but the documentation will only show the second route because in `openapi.json`, route names will be `/app/{user}` for both routes. 131 | 132 | # Route Options 133 | 134 | - **path**: the URL rule as string 135 | - **methods**: list of HTTP methods allowed, default: ["GET"] 136 | - **cors**: allow CORS, default: `False` 137 | - **token**: set `access_token` validation 138 | - **payload_compression_method**: Enable and select an output body compression 139 | - **binary_b64encode**: base64 encode the output body (API Gateway) 140 | - **ttl**: Cache Control setting (Time to Live) **(Deprecated in 6.0.0)** 141 | - **cache_control**: Cache Control setting 142 | - **description**: route description (for documentation) 143 | - **tag**: list of tags (for documentation) 144 | 145 | ## Cache Control 146 | 147 | Add a Cache Control header with a Time to Live (TTL) in seconds. 148 | 149 | ```python 150 | from lambda_proxy.proxy import API 151 | APP = API(app_name="app") 152 | 153 | @APP.get('/test/tests/', cors=True, cache_control="public,max-age=3600") 154 | def print_id(id): 155 | return ('OK', 'plain/text', id) 156 | ``` 157 | 158 | Note: If function returns other then "OK", Cache-Control will be set to `no-cache` 159 | 160 | ## Binary responses 161 | 162 | When working with binary on API-Gateway we must return a base64 encoded string 163 | 164 | ```python 165 | from lambda_proxy.proxy import API 166 | 167 | APP = API(name="app") 168 | 169 | @APP.get('/test/tests/.jpg', cors=True, binary_b64encode=True) 170 | def print_id(filename): 171 | with open(f"{filename}.jpg", "rb") as f: 172 | return ('OK', 'image/jpeg', f.read()) 173 | ``` 174 | 175 | ## Compression 176 | 177 | Enable compression if "Accept-Encoding" if found in headers. 178 | 179 | ```python 180 | from lambda_proxy.proxy import API 181 | 182 | APP = API(name="app") 183 | 184 | @APP.get( 185 | '/test/tests/.jpg', 186 | cors=True, 187 | binary_b64encode=True, 188 | payload_compression_method="gzip" 189 | ) 190 | def print_id(filename): 191 | with open(f"{filename}.jpg", "rb") as f: 192 | return ('OK', 'image/jpeg', f.read()) 193 | ``` 194 | 195 | ## Simple Auth token 196 | 197 | Lambda-proxy provide a simple token validation system. 198 | 199 | - a "TOKEN" variable must be set in the environment 200 | - each request must provide a "access_token" params (e.g curl 201 | http://myurl/test/tests/myid?access_token=blabla) 202 | 203 | ```python 204 | from lambda_proxy.proxy import API 205 | 206 | APP = API(name="app") 207 | 208 | @APP.get('/test/tests/', cors=True, token=True) 209 | def print_id(id): 210 | return ('OK', 'plain/text', id) 211 | ``` 212 | 213 | ## URL schema and request parameters 214 | 215 | QueryString parameters are passed as function's options. 216 | 217 | ```python 218 | from lambda_proxy.proxy import API 219 | 220 | APP = API(name="app") 221 | 222 | @APP.get('/', cors=True) 223 | def print_id(id, name=None): 224 | return ('OK', 'plain/text', f"{id}{name}") 225 | ``` 226 | 227 | requests: 228 | 229 | ```bash 230 | $ curl /000001 231 | 0001 232 | 233 | $ curl /000001?name=vincent 234 | 0001vincent 235 | ``` 236 | 237 | ## Multiple Routes 238 | 239 | ```python 240 | from lambda_proxy.proxy import API 241 | APP = API(name="app") 242 | 243 | @APP.get('/', cors=True) 244 | @APP.get('//', cors=True) 245 | def print_id(id, number=None, name=None): 246 | return ('OK', 'plain/text', f"{id}-{name}-{number}") 247 | ``` 248 | requests: 249 | 250 | ```bash 251 | 252 | $ curl /000001 253 | 0001-- 254 | 255 | $ curl /000001?name=vincent 256 | 0001-vincent- 257 | 258 | $ curl /000001/1?name=vincent 259 | 0001-vincent-1 260 | ``` 261 | 262 | # Advanced features 263 | 264 | ## Context and Event passing 265 | 266 | Pass event and context to the handler function. 267 | 268 | ```python 269 | from lambda_proxy.proxy import API 270 | 271 | APP = API(name="app") 272 | 273 | @APP.get("/", cors=True) 274 | @APP.pass_event 275 | @APP.pass_context 276 | def print_id(ctx, evt, id): 277 | print(ctx) 278 | print(evt) 279 | return ('OK', 'plain/text', f"{id}") 280 | ``` 281 | 282 | # Automatic OpenAPI documentation 283 | 284 | By default the APP (`lambda_proxy.proxy.API`) is provided with three (3) routes: 285 | - `/openapi.json`: print OpenAPI JSON definition 286 | 287 | - `/docs`: swagger html UI 288 | ![swagger](https://user-images.githubusercontent.com/10407788/58707335-9cbb0480-8382-11e9-927f-8d992cf2531a.jpg) 289 | 290 | - `/redoc`: Redoc html UI 291 | ![redoc](https://user-images.githubusercontent.com/10407788/58707338-9dec3180-8382-11e9-8dec-18173e39258f.jpg) 292 | 293 | **Function annotations** 294 | 295 | To be able to render full and precise API documentation, lambda_proxy uses python type hint and annotations [link](https://www.python.org/dev/peps/pep-3107/). 296 | 297 | ```python 298 | from lambda_proxy.proxy import API 299 | 300 | APP = API(name="app") 301 | 302 | @APP.route('/test/', methods=['GET'], cors=True) 303 | def print_id(id: int, num: float = 0.2) -> Tuple(str, str, str): 304 | return ('OK', 'plain/text', id) 305 | ``` 306 | 307 | In the example above, our route `/test/` define an input `id` to be a `INT`, while we also add this hint to the function `print_id` we also specify the type (and default) of the `num` option. 308 | 309 | # Custom Domain and path mapping 310 | 311 | Since version 4.1.1, lambda-proxy support custom domain and path mapping (see https://github.com/vincentsarago/lambda-proxy/issues/16). 312 | 313 | Note: When using path mapping other than `root` (`/`), `/` route won't be available. 314 | 315 | ```python 316 | from lambda_proxy.proxy import API 317 | 318 | api = API(name="api", debug=True) 319 | 320 | 321 | # This route won't work when using path mapping 322 | @api.get("/", cors=True) 323 | # This route will work only if the path mapping is set to /api 324 | @api.get("/api", cors=True) 325 | def index(): 326 | html = """ 327 | 328 |
This is title
329 | 330 | Hello world 331 | 332 | """ 333 | return ("OK", "text/html", html) 334 | 335 | 336 | @api.get("/yo", cors=True) 337 | def yo(): 338 | return ("OK", "text/plain", "YOOOOO") 339 | ``` 340 | 341 | # Plugin 342 | 343 | - Add cache layer: https://github.com/vincentsarago/lambda-proxy-cache 344 | 345 | 346 | # Examples 347 | 348 | - https://github.com/vincentsarago/lambda-proxy/tree/master/example 349 | - https://github.com/RemotePixel/remotepixel-tiler 350 | 351 | 352 | # Contribution & Devellopement 353 | 354 | Issues and pull requests are more than welcome. 355 | 356 | **Dev install & Pull-Request** 357 | 358 | ```bash 359 | $ git clone https://github.com/vincentsarago/lambda-proxy.git 360 | $ cd lambda-proxy 361 | $ pip install -e .[dev] 362 | ``` 363 | 364 | **Python3.7 only** 365 | 366 | This repo is set to use pre-commit to run *mypy*, *flake8*, *pydocstring* and *black* ("uncompromising Python code formatter") when committing new code. 367 | 368 | ```bash 369 | $ pre-commit install 370 | $ git add . 371 | $ git commit -m'my change' 372 | black.........................Passed 373 | Flake8........................Passed 374 | Verifying PEP257 Compliance...Passed 375 | mypy..........................Passed 376 | $ git push origin 377 | ``` 378 | 379 | ### License 380 | 381 | See [LICENSE.txt](/LICENSE.txt>). 382 | 383 | ### Authors 384 | 385 | See [AUTHORS.txt](/AUTHORS.txt>). 386 | 387 | ### Changes 388 | 389 | See [CHANGES.txt](/CHANGES.txt>). 390 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | comment: off 2 | 3 | coverage: 4 | status: 5 | project: 6 | default: 7 | target: auto 8 | threshold: 5 9 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | Lambda-proxy example 2 | -------------------- 3 | 4 | ``` 5 | $ git clone https://github.com/vincentsarag/lambda-proxy.git 6 | $ cd lambda-proxy 7 | $ pip install -U pip 8 | $ pip install -e . 9 | 10 | $ cd example 11 | 12 | $ python cli.py 13 | ``` 14 | #### txt 15 | 16 | 17 | ```python 18 | @APP.route( 19 | "/", 20 | methods=["GET"], 21 | cors=True, 22 | ) 23 | def main(user): 24 | """Return JSON Object.""" 25 | return ("OK", "text/plain", user) 26 | ``` 27 | 28 | ``` 29 | $ curl -i http://127.0.0.1:8000/ 30 | 31 | > HTTP/1.0 200 OK 32 | > Server: BaseHTTP/0.6 Python/3.7.0 33 | > Date: Tue, 29 Jan 2019 19:54:07 GMT 34 | > Content-Type: text/plain 35 | > Access-Control-Allow-Origin: * 36 | > Access-Control-Allow-Methods: GET 37 | > Access-Control-Allow-Credentials: true 38 | 39 | YO% 40 | ``` 41 | 42 | ##### With multiple routes 43 | ``` 44 | @APP.route("/", methods=["GET"], cors=True) 45 | @APP.route("/@", methods=["GET"], cors=True) 46 | def main(user, num=0): 47 | """Return JSON Object.""" 48 | return ("OK", "text/plain", f"{user}-{num}") 49 | ``` 50 | 51 | ``` 52 | $ curl -i http://127.0.0.1:8000/jqtrde 53 | 54 | jqtrde-0% 55 | 56 | $ curl -i http://127.0.0.1:8000/jqtrde@2 57 | 58 | jqtrde-2% 59 | ``` 60 | 61 | #### json 62 | 63 | ```python 64 | @APP.route( 65 | "/json", 66 | methods=["GET"], 67 | cors=True, 68 | ) 69 | def json_handler(): 70 | """Return JSON Object.""" 71 | return ("OK", "application/json", json.dumps({"app": "it works"})) 72 | ``` 73 | 74 | ``` 75 | $ curl -i http://127.0.0.1:8000/json 76 | 77 | > HTTP/1.0 200 OK 78 | > Server: BaseHTTP/0.6 Python/3.7.0 79 | > Date: Tue, 29 Jan 2019 19:55:00 GMT 80 | > Content-Type: application/json 81 | > Access-Control-Allow-Origin: * 82 | > Access-Control-Allow-Methods: GET 83 | > Access-Control-Allow-Credentials: true 84 | 85 | {"app": "it works"}% 86 | ``` 87 | 88 | ## Binary 89 | 90 | ```python 91 | @APP.route( 92 | "/binary", 93 | methods=["GET"], 94 | cors=True, 95 | payload_compression_method="gzip", 96 | ) 97 | def bin(): 98 | """Return image.""" 99 | with open("./rpix.png", "rb") as f: 100 | return ( 101 | "OK", 102 | "image/png", 103 | f.read() 104 | ) 105 | ``` 106 | 107 | #### Simple 108 | ``` 109 | curl -v http://127.0.0.1:8000/binary > image.png 110 | ... 111 | > GET /binary HTTP/1.1 112 | > Host: 127.0.0.1:8000 113 | > User-Agent: curl/7.54.0 114 | > Accept: */* 115 | > 116 | * HTTP 1.0, assume close after body 117 | < HTTP/1.0 200 OK 118 | < Server: BaseHTTP/0.6 Python/3.7.0 119 | < Date: Tue, 29 Jan 2019 19:57:09 GMT 120 | < Content-Type: image/png 121 | < Access-Control-Allow-Origin: * 122 | < Access-Control-Allow-Methods: GET 123 | < Access-Control-Allow-Credentials: true 124 | < 125 | ... 126 | ``` 127 | 128 | #### Compressed 129 | ``` 130 | $ curl -v --compressed http://127.0.0.1:8000/binary > image.png 131 | ... 132 | > GET /binary HTTP/1.1 133 | > Host: 127.0.0.1:8000 134 | > User-Agent: curl/7.54.0 135 | > Accept: */* 136 | > Accept-Encoding: deflate, gzip 137 | > 138 | * HTTP 1.0, assume close after body 139 | < HTTP/1.0 200 OK 140 | < Server: BaseHTTP/0.6 Python/3.7.0 141 | < Date: Tue, 29 Jan 2019 19:56:14 GMT 142 | < Content-Type: image/png 143 | < Access-Control-Allow-Origin: * 144 | < Access-Control-Allow-Methods: GET 145 | < Access-Control-Allow-Credentials: true 146 | < Content-Encoding: gzip 147 | < 148 | ``` 149 | 150 | #### base 64 (api-gateway) 151 | 152 | ```python 153 | @APP.route( 154 | "/b64binary", 155 | methods=["GET"], 156 | cors=True, 157 | payload_compression_method="gzip", 158 | binary_b64encode=True 159 | ) 160 | def b64bin(): 161 | """Return base64 encoded image.""" 162 | with open("./rpix.png", "rb") as f: 163 | return ( 164 | "OK", 165 | "image/png", 166 | f.read() 167 | ) 168 | ``` 169 | 170 | ``` 171 | curl -v http://127.0.0.1:8000/b64binary | base64 --decode > image.png 172 | ... 173 | > GET /b64binary HTTP/1.1 174 | > Host: 127.0.0.1:8000 175 | > User-Agent: curl/7.54.0 176 | > Accept: */* 177 | > 178 | * HTTP 1.0, assume close after body 179 | < HTTP/1.0 200 OK 180 | < Server: BaseHTTP/0.6 Python/3.7.0 181 | < Date: Tue, 29 Jan 2019 20:07:53 GMT 182 | < Content-Type: image/png 183 | < Access-Control-Allow-Origin: * 184 | < Access-Control-Allow-Methods: GET 185 | < Access-Control-Allow-Credentials: true 186 | ... 187 | ``` 188 | -------------------------------------------------------------------------------- /example/cli.py: -------------------------------------------------------------------------------- 1 | """Launch server""" 2 | 3 | import base64 4 | 5 | import click 6 | from urllib.parse import urlparse, parse_qsl 7 | 8 | from http.server import HTTPServer, BaseHTTPRequestHandler 9 | 10 | from handler import app 11 | 12 | 13 | class HTTPRequestHandler(BaseHTTPRequestHandler): 14 | """Requests handler.""" 15 | 16 | def do_GET(self): 17 | """Get requests.""" 18 | q = urlparse(self.path) 19 | request = { 20 | "headers": dict(self.headers), 21 | "path": q.path, 22 | "queryStringParameters": dict(parse_qsl(q.query)), 23 | "httpMethod": self.command, 24 | } 25 | response = app(request, None) 26 | 27 | self.send_response(int(response["statusCode"])) 28 | for r in response["headers"]: 29 | self.send_header(r, response["headers"][r]) 30 | self.end_headers() 31 | 32 | if isinstance(response["body"], str): 33 | self.wfile.write(bytes(response["body"], "utf-8")) 34 | else: 35 | self.wfile.write(response["body"]) 36 | 37 | def do_POST(self): 38 | """POST requests.""" 39 | q = urlparse(self.path) 40 | body = self.rfile.read(int(dict(self.headers).get("Content-Length"))) 41 | body = base64.b64encode(body).decode() 42 | request = { 43 | "headers": dict(self.headers), 44 | "path": q.path, 45 | "queryStringParameters": dict(parse_qsl(q.query)), 46 | "body": body, 47 | "httpMethod": self.command, 48 | "isBase64Encoded": True, 49 | } 50 | response = app(request, None) 51 | 52 | self.send_response(int(response["statusCode"])) 53 | for r in response["headers"]: 54 | self.send_header(r, response["headers"][r]) 55 | self.end_headers() 56 | 57 | if isinstance(response["body"], str): 58 | self.wfile.write(bytes(response["body"], "utf-8")) 59 | else: 60 | self.wfile.write(response["body"]) 61 | 62 | 63 | @click.command(short_help="Local Server") 64 | @click.option("--port", type=int, default=8000, help="port") 65 | def run(port): 66 | """Launch server.""" 67 | server_address = ("", port) 68 | httpd = HTTPServer(server_address, HTTPRequestHandler) 69 | click.echo(f"Starting local server at http://127.0.0.1:{port}", err=True) 70 | httpd.serve_forever() 71 | 72 | 73 | if __name__ == "__main__": 74 | run() 75 | -------------------------------------------------------------------------------- /example/handler.py: -------------------------------------------------------------------------------- 1 | """app: handle requests.""" 2 | 3 | from typing import Dict, Tuple 4 | import typing.io 5 | 6 | import json 7 | 8 | from lambda_proxy.proxy import API 9 | 10 | app = API(name="app", debug=True) 11 | 12 | 13 | @app.get("/", cors=True) 14 | def main() -> Tuple[str, str, str]: 15 | """Return JSON Object.""" 16 | return ("OK", "text/plain", "Yo") 17 | 18 | 19 | @app.get("/", cors=True) 20 | def _re_one(regex1: str) -> Tuple[str, str, str]: 21 | """Return JSON Object.""" 22 | return ("OK", "text/plain", regex1) 23 | 24 | 25 | @app.get("/", cors=True) 26 | def _re_two(regex2: str) -> Tuple[str, str, str]: 27 | """Return JSON Object.""" 28 | return ("OK", "text/plain", regex2) 29 | 30 | 31 | @app.post("/people", cors=True) 32 | def people_post(body) -> Tuple[str, str, str]: 33 | """Return JSON Object.""" 34 | return ("OK", "text/plain", body) 35 | 36 | 37 | @app.get("/people", cors=True) 38 | def people_get() -> Tuple[str, str, str]: 39 | """Return JSON Object.""" 40 | return ("OK", "text/plain", "Nope") 41 | 42 | 43 | @app.get("/", cors=True) 44 | @app.get("//", cors=True) 45 | def double(user: str, num: int = 0) -> Tuple[str, str, str]: 46 | """Return JSON Object.""" 47 | return ("OK", "text/plain", f"{user}-{num}") 48 | 49 | 50 | @app.get("/kw/", cors=True) 51 | def kw_method(user: str, **kwargs: Dict) -> Tuple[str, str, str]: 52 | """Return JSON Object.""" 53 | return ("OK", "text/plain", f"{user}") 54 | 55 | 56 | @app.get("/ctx/", cors=True) 57 | @app.pass_context 58 | @app.pass_event 59 | def ctx_method(evt: Dict, ctx: Dict, user: str, num: int = 0) -> Tuple[str, str, str]: 60 | """Return JSON Object.""" 61 | return ("OK", "text/plain", f"{user}-{num}") 62 | 63 | 64 | @app.get("/json", cors=True) 65 | def json_handler() -> Tuple[str, str, str]: 66 | """Return JSON Object.""" 67 | return ("OK", "application/json", json.dumps({"app": "it works"})) 68 | 69 | 70 | @app.get("/binary", cors=True, payload_compression_method="gzip") 71 | def bin() -> Tuple[str, str, typing.io.BinaryIO]: 72 | """Return image.""" 73 | with open("./rpix.png", "rb") as f: 74 | return ("OK", "image/png", f.read()) 75 | 76 | 77 | @app.get( 78 | "/b64binary", cors=True, payload_compression_method="gzip", binary_b64encode=True, 79 | ) 80 | def b64bin() -> Tuple[str, str, typing.io.BinaryIO]: 81 | """Return base64 encoded image.""" 82 | with open("./rpix.png", "rb") as f: 83 | return ("OK", "image/png", f.read()) 84 | -------------------------------------------------------------------------------- /example/rpix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vincentsarago/lambda-proxy/c39d0397bfcb324e24db52466193bb145c9d92a7/example/rpix.png -------------------------------------------------------------------------------- /lambda_proxy/__init__.py: -------------------------------------------------------------------------------- 1 | """lambda-proxy: A simple AWS Lambda proxy to handle API Gateway request.""" 2 | 3 | import pkg_resources 4 | 5 | version = pkg_resources.get_distribution(__package__).version 6 | -------------------------------------------------------------------------------- /lambda_proxy/proxy.py: -------------------------------------------------------------------------------- 1 | """Translate request from AWS api-gateway. 2 | 3 | Freely adapted from https://github.com/aws/chalice 4 | 5 | """ 6 | from typing import Any, Callable, Dict, List, Optional, Tuple, Sequence, Union 7 | 8 | import inspect 9 | 10 | import os 11 | import re 12 | import sys 13 | import json 14 | import zlib 15 | import base64 16 | import logging 17 | import warnings 18 | from functools import wraps 19 | 20 | from lambda_proxy import templates 21 | 22 | params_expr = re.compile(r"(<[^>]*>)") 23 | proxy_pattern = re.compile(r"/{(?P.+)\+}$") 24 | param_pattern = re.compile( 25 | r"^<((?P[a-zA-Z0-9_]+)(\((?P.+)\))?\:)?(?P[a-zA-Z0-9_]+)>$" 26 | ) 27 | regex_pattern = re.compile( 28 | r"^<(?Pregex)\((?P.+)\):(?P[a-zA-Z0-9_]+)>$" 29 | ) 30 | 31 | 32 | def _path_to_regex(path: str) -> str: 33 | path = f"^{path}$" # full match 34 | path = re.sub(r"<[a-zA-Z0-9_]+>", r"([a-zA-Z0-9_]+)", path) 35 | path = re.sub(r"", r"([a-zA-Z0-9_]+)", path) 36 | path = re.sub(r"", r"([0-9]+)", path) 37 | path = re.sub(r"", "([+-]?[0-9]+.[0-9]+)", path) 38 | path = re.sub( 39 | r"", 40 | "([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})", 41 | path, 42 | ) 43 | for regexParam in re.findall(r"(]*>)", path): 44 | matches = regex_pattern.search(regexParam) 45 | expr = matches.groupdict()["pattern"] 46 | path = path.replace(regexParam, f"({expr})") 47 | 48 | return path 49 | 50 | 51 | def _path_to_openapi(path: str) -> str: 52 | for regexParam in re.findall(r"(]*>)", path): 53 | match = regex_pattern.search(regexParam).groupdict() 54 | name = match["name"] 55 | path = path.replace(regexParam, f"") 56 | 57 | path = re.sub(r"<([a-zA-Z0-9_]+\:)?", "{", path) 58 | return re.sub(r">", "}", path) 59 | 60 | 61 | def _converters(value: str, pathArg: str) -> Any: 62 | match = param_pattern.match(pathArg) 63 | if match: 64 | arg_type = match.groupdict()["type"] 65 | if arg_type == "int": 66 | return int(value) 67 | elif arg_type == "float": 68 | return float(value) 69 | elif arg_type == "string": 70 | return value 71 | elif arg_type == "uuid": 72 | return value 73 | else: 74 | return value 75 | else: 76 | return value 77 | 78 | 79 | class RouteEntry(object): 80 | """Decode request path.""" 81 | 82 | def __init__( 83 | self, 84 | endpoint: Callable, 85 | path: str, 86 | methods: List = ["GET"], 87 | cors: bool = False, 88 | token: bool = False, 89 | payload_compression_method: str = "", 90 | binary_b64encode: bool = False, 91 | ttl=None, 92 | cache_control=None, 93 | description: str = None, 94 | tag: Tuple = None, 95 | ) -> None: 96 | """Initialize route object.""" 97 | self.endpoint = endpoint 98 | self.path = path 99 | self.route_regex = _path_to_regex(path) 100 | self.openapi_path = _path_to_openapi(self.path) 101 | self.methods = methods 102 | self.cors = cors 103 | self.token = token 104 | self.compression = payload_compression_method 105 | self.b64encode = binary_b64encode 106 | self.ttl = ttl 107 | self.cache_control = cache_control 108 | self.description = description or self.endpoint.__doc__ 109 | self.tag = tag 110 | if self.compression and self.compression not in ["gzip", "zlib", "deflate"]: 111 | raise ValueError( 112 | f"'{payload_compression_method}' is not a supported compression" 113 | ) 114 | 115 | def __eq__(self, other) -> bool: 116 | """Check for equality.""" 117 | return self.__dict__ == other.__dict__ 118 | 119 | def _get_path_args(self) -> Sequence[Any]: 120 | route_args = [i.group() for i in params_expr.finditer(self.path)] 121 | args = [param_pattern.match(arg).groupdict() for arg in route_args] 122 | return args 123 | 124 | 125 | def _get_apigw_stage(event: Dict) -> str: 126 | """Return API Gateway stage name.""" 127 | header = event.get("headers", {}) 128 | host = header.get("x-forwarded-host", header.get("host", "")) 129 | if ".execute-api." in host and ".amazonaws.com" in host: 130 | stage = event["requestContext"].get("stage", "") 131 | return stage 132 | return "" 133 | 134 | 135 | def _get_request_path(event: Dict) -> Optional[str]: 136 | """Return API call path.""" 137 | resource_proxy = proxy_pattern.search(event.get("resource", "/")) 138 | if resource_proxy: 139 | proxy_path = event["pathParameters"].get(resource_proxy["name"]) 140 | return f"/{proxy_path}" 141 | 142 | return event.get("path") 143 | 144 | 145 | class ApigwPath(object): 146 | """Parse path of API Call.""" 147 | 148 | def __init__(self, event: Dict): 149 | """Initialize API Gateway Path Info object.""" 150 | self.version = event.get("version") 151 | self.apigw_stage = _get_apigw_stage(event) 152 | self.path = _get_request_path(event) 153 | self.api_prefix = proxy_pattern.sub("", event.get("resource", "")).rstrip("/") 154 | if not self.apigw_stage and self.path: 155 | path = event.get("path", "") 156 | suffix = self.api_prefix + self.path 157 | self.path_mapping = path.replace(suffix, "") 158 | else: 159 | self.path_mapping = "" 160 | 161 | @property 162 | def prefix(self): 163 | """Return the API prefix.""" 164 | if self.apigw_stage and not self.apigw_stage == "$default": 165 | return f"/{self.apigw_stage}" + self.api_prefix 166 | elif self.path_mapping: 167 | return self.path_mapping + self.api_prefix 168 | else: 169 | return self.api_prefix 170 | 171 | 172 | class API(object): 173 | """API.""" 174 | 175 | FORMAT_STRING = "[%(name)s] - [%(levelname)s] - %(message)s" 176 | 177 | def __init__( 178 | self, 179 | name: str, 180 | version: str = "0.0.1", 181 | description: str = None, 182 | add_docs: bool = True, 183 | configure_logs: bool = True, 184 | debug: bool = False, 185 | https: bool = True, 186 | ) -> None: 187 | """Initialize API object.""" 188 | self.name: str = name 189 | self.description: Optional[str] = description 190 | self.version: str = version 191 | self.routes: List[RouteEntry] = [] 192 | self.context: Dict = {} 193 | self.event: Dict = {} 194 | self.request_path: ApigwPath 195 | self.debug: bool = debug 196 | self.https: bool = https 197 | self.log = logging.getLogger(self.name) 198 | if configure_logs: 199 | self._configure_logging() 200 | if add_docs: 201 | self.setup_docs() 202 | 203 | @property 204 | def host(self) -> str: 205 | """Construct api gateway endpoint url.""" 206 | host = self.event["headers"].get( 207 | "x-forwarded-host", self.event["headers"].get("host", "") 208 | ) 209 | path_info = self.request_path 210 | if path_info.apigw_stage and not path_info.apigw_stage == "$default": 211 | host_suffix = f"/{path_info.apigw_stage}" 212 | else: 213 | host_suffix = path_info.path_mapping 214 | 215 | scheme = "https" if self.https else "http" 216 | return f"{scheme}://{host}{host_suffix}" 217 | 218 | def _get_parameters(self, route: RouteEntry) -> List[Dict]: 219 | argspath_schema = { 220 | "default": {"type": "string"}, 221 | "string": {"type": "string"}, 222 | "str": {"type": "string"}, 223 | "regex": {"type": "string", "pattern": ""}, 224 | "uuid": {"type": "string", "format": "uuid"}, 225 | "int": {"type": "integer"}, 226 | "float": {"type": "number", "format": "float"}, 227 | } 228 | 229 | args_in_path = route._get_path_args() 230 | endpoint_args = inspect.signature(route.endpoint).parameters 231 | endpoint_args_names = list(endpoint_args.keys()) 232 | 233 | parameters: List[Dict] = [] 234 | for arg in args_in_path: 235 | annotation = endpoint_args[arg["name"]] 236 | endpoint_args_names.remove(arg["name"]) 237 | 238 | parameter = { 239 | "name": arg["name"], 240 | "in": "path", 241 | "schema": {"type": "string"}, 242 | } 243 | 244 | if arg["type"] is not None: 245 | parameter["schema"] = argspath_schema[arg["type"]] 246 | if arg["type"] == "regex": 247 | parameter["schema"]["pattern"] = f"^{arg['pattern']}$" 248 | 249 | if annotation.default is not inspect.Parameter.empty: 250 | parameter["schema"]["default"] = annotation.default 251 | else: 252 | parameter["required"] = True 253 | 254 | parameters.append(parameter) 255 | 256 | for name, arg in endpoint_args.items(): 257 | if name not in endpoint_args_names: 258 | continue 259 | parameter = {"name": name, "in": "query", "schema": {}} 260 | if arg.default is not inspect.Parameter.empty: 261 | parameter["schema"]["default"] = arg.default 262 | elif arg.kind == inspect.Parameter.VAR_KEYWORD: 263 | parameter["schema"]["format"] = "dict" 264 | else: 265 | parameter["schema"]["format"] = "string" 266 | parameter["required"] = True 267 | 268 | parameters.append(parameter) 269 | return parameters 270 | 271 | def _get_openapi( 272 | self, openapi_version: str = "3.0.2", openapi_prefix: str = "" 273 | ) -> Dict: 274 | """Get OpenAPI documentation.""" 275 | info = {"title": self.name, "version": self.version} 276 | if self.description: 277 | info["description"] = self.description 278 | output = {"openapi": openapi_version, "info": info} 279 | 280 | security_schemes = { 281 | "access_token": { 282 | "type": "apiKey", 283 | "description": "Simple token authentification", 284 | "in": "query", 285 | "name": "access_token", 286 | } 287 | } 288 | 289 | components: Dict[str, Dict] = {} 290 | paths: Dict[str, Dict] = {} 291 | 292 | for route in self.routes: 293 | path: Dict[str, Dict] = {} 294 | 295 | default_operation: Dict[str, Any] = {} 296 | if route.tag: 297 | default_operation["tags"] = route.tag 298 | if route.description: 299 | default_operation["description"] = route.description 300 | if route.token: 301 | components.setdefault("securitySchemes", {}).update(security_schemes) 302 | default_operation["security"] = [{"access_token": []}] 303 | 304 | parameters = self._get_parameters(route) 305 | if parameters: 306 | default_operation["parameters"] = parameters 307 | 308 | default_operation["responses"] = { 309 | 400: {"description": "Not found"}, 310 | 500: {"description": "Internal error"}, 311 | } 312 | 313 | for method in route.methods: 314 | operation = default_operation.copy() 315 | operation["operationId"] = route.openapi_path 316 | if method in ["PUT", "POST", "DELETE", "PATCH"]: 317 | operation["requestBody"] = { 318 | "description": "Body", 319 | "content": {"*/*": {}}, 320 | "required": operation["parameters"][0].get("required", "False"), 321 | } 322 | operation["parameters"] = operation["parameters"][1:] 323 | 324 | path[method.lower()] = operation 325 | 326 | paths.setdefault(openapi_prefix + route.openapi_path, {}).update(path) 327 | 328 | if components: 329 | output["components"] = components 330 | 331 | output["paths"] = paths 332 | return output 333 | 334 | def _configure_logging(self) -> None: 335 | if self._already_configured(self.log): 336 | return 337 | 338 | handler = logging.StreamHandler(sys.stdout) 339 | # Timestamp is handled by lambda itself so the 340 | # default FORMAT_STRING doesn't need to include it. 341 | formatter = logging.Formatter(self.FORMAT_STRING) 342 | handler.setFormatter(formatter) 343 | self.log.propagate = False 344 | if self.debug: 345 | level = logging.DEBUG 346 | else: 347 | level = logging.ERROR 348 | self.log.setLevel(level) 349 | self.log.addHandler(handler) 350 | 351 | def _already_configured(self, log) -> bool: 352 | if not log.handlers: 353 | return False 354 | 355 | for handler in log.handlers: 356 | if isinstance(handler, logging.StreamHandler): 357 | if handler.stream == sys.stdout: 358 | return True 359 | 360 | return False 361 | 362 | def _add_route(self, path: str, endpoint: Callable, **kwargs) -> None: 363 | methods = kwargs.pop("methods", ["GET"]) 364 | cors = kwargs.pop("cors", False) 365 | token = kwargs.pop("token", "") 366 | payload_compression = kwargs.pop("payload_compression_method", "") 367 | binary_encode = kwargs.pop("binary_b64encode", False) 368 | ttl = kwargs.pop("ttl", None) 369 | cache_control = kwargs.pop("cache_control", None) 370 | description = kwargs.pop("description", None) 371 | tag = kwargs.pop("tag", None) 372 | 373 | if ttl: 374 | warnings.warn( 375 | "ttl will be deprecated in 6.0.0, please use 'cache-control'", 376 | DeprecationWarning, 377 | stacklevel=2, 378 | ) 379 | 380 | if kwargs: 381 | raise TypeError( 382 | "TypeError: route() got unexpected keyword " 383 | "arguments: %s" % ", ".join(list(kwargs)) 384 | ) 385 | 386 | for method in methods: 387 | if self._checkroute(path, method): 388 | raise ValueError( 389 | 'Duplicate route detected: "{}"\n' 390 | "URL paths must be unique.".format(path) 391 | ) 392 | 393 | route = RouteEntry( 394 | endpoint, 395 | path, 396 | methods, 397 | cors, 398 | token, 399 | payload_compression, 400 | binary_encode, 401 | ttl, 402 | cache_control, 403 | description, 404 | tag, 405 | ) 406 | self.routes.append(route) 407 | 408 | def _checkroute(self, path: str, method: str) -> bool: 409 | for route in self.routes: 410 | if method in route.methods and path == route.path: 411 | return True 412 | return False 413 | 414 | def _url_matching(self, url: str, method: str) -> Optional[RouteEntry]: 415 | for route in self.routes: 416 | expr = re.compile(route.route_regex) 417 | if method in route.methods and expr.match(url): 418 | return route 419 | 420 | return None 421 | 422 | def _get_matching_args(self, route: RouteEntry, url: str) -> Dict: 423 | route_expr = re.compile(route.route_regex) 424 | route_args = [i.group() for i in params_expr.finditer(route.path)] 425 | url_args = route_expr.match(url).groups() 426 | 427 | names = [param_pattern.match(arg).groupdict()["name"] for arg in route_args] 428 | 429 | args = [ 430 | _converters(u, route_args[id]) 431 | for id, u in enumerate(url_args) 432 | if u != route_args[id] 433 | ] 434 | 435 | return dict(zip(names, args)) 436 | 437 | def _validate_token(self, token: str = None) -> bool: 438 | env_token = os.environ.get("TOKEN") 439 | 440 | if not token or not env_token: 441 | return False 442 | 443 | if token == env_token: 444 | return True 445 | 446 | return False 447 | 448 | def route(self, path: str, **kwargs) -> Callable: 449 | """Register route.""" 450 | 451 | def _register_view(endpoint): 452 | self._add_route(path, endpoint, **kwargs) 453 | return endpoint 454 | 455 | return _register_view 456 | 457 | def get(self, path: str, **kwargs) -> Callable: 458 | """Register GET route.""" 459 | kwargs.update(dict(methods=["GET"])) 460 | 461 | def _register_view(endpoint): 462 | self._add_route(path, endpoint, **kwargs) 463 | return endpoint 464 | 465 | return _register_view 466 | 467 | def post(self, path: str, **kwargs) -> Callable: 468 | """Register POST route.""" 469 | kwargs.update(dict(methods=["POST"])) 470 | 471 | def _register_view(endpoint): 472 | self._add_route(path, endpoint, **kwargs) 473 | return endpoint 474 | 475 | return _register_view 476 | 477 | def pass_context(self, f: Callable) -> Callable: 478 | """Decorator: pass the API Gateway context to the function.""" 479 | 480 | @wraps(f) 481 | def new_func(*args, **kwargs) -> Callable: 482 | return f(self.context, *args, **kwargs) 483 | 484 | return new_func 485 | 486 | def pass_event(self, f: Callable) -> Callable: 487 | """Decorator: pass the API Gateway event to the function.""" 488 | 489 | @wraps(f) 490 | def new_func(*args, **kwargs) -> Callable: 491 | return f(self.event, *args, **kwargs) 492 | 493 | return new_func 494 | 495 | def setup_docs(self) -> None: 496 | """Add default documentation routes.""" 497 | openapi_url = f"/openapi.json" 498 | 499 | def _openapi() -> Tuple[str, str, str]: 500 | """Return OpenAPI json.""" 501 | return ( 502 | "OK", 503 | "application/json", 504 | json.dumps(self._get_openapi(openapi_prefix=self.request_path.prefix)), 505 | ) 506 | 507 | self._add_route(openapi_url, _openapi, cors=True, tag=["documentation"]) 508 | 509 | def _swagger_ui_html() -> Tuple[str, str, str]: 510 | """Display Swagger HTML UI.""" 511 | openapi_prefix = self.request_path.prefix 512 | return ( 513 | "OK", 514 | "text/html", 515 | templates.swagger( 516 | openapi_url=f"{openapi_prefix}{openapi_url}", 517 | title=self.name + " - Swagger UI", 518 | ), 519 | ) 520 | 521 | self._add_route("/docs", _swagger_ui_html, cors=True, tag=["documentation"]) 522 | 523 | def _redoc_ui_html() -> Tuple[str, str, str]: 524 | """Display Redoc HTML UI.""" 525 | openapi_prefix = self.request_path.prefix 526 | return ( 527 | "OK", 528 | "text/html", 529 | templates.redoc( 530 | openapi_url=f"{openapi_prefix}{openapi_url}", 531 | title=self.name + " - ReDoc", 532 | ), 533 | ) 534 | 535 | self._add_route("/redoc", _redoc_ui_html, cors=True, tag=["documentation"]) 536 | 537 | def response( 538 | self, 539 | status: Union[int, str], 540 | content_type: str, 541 | response_body: Any, 542 | cors: bool = False, 543 | accepted_methods: Sequence = [], 544 | accepted_compression: str = "", 545 | compression: str = "", 546 | b64encode: bool = False, 547 | ttl: int = None, 548 | cache_control: str = None, 549 | ): 550 | """Return HTTP response. 551 | 552 | including response code (status), headers and body 553 | 554 | """ 555 | statusCode = { 556 | "OK": 200, 557 | "EMPTY": 204, 558 | "NOK": 400, 559 | "FOUND": 302, 560 | "NOT_FOUND": 404, 561 | "CONFLICT": 409, 562 | "ERROR": 500, 563 | } 564 | 565 | binary_types = [ 566 | "application/octet-stream", 567 | "application/x-protobuf", 568 | "application/x-tar", 569 | "application/zip", 570 | "image/png", 571 | "image/jpeg", 572 | "image/jpg", 573 | "image/tiff", 574 | "image/webp", 575 | "image/jp2", 576 | ] 577 | 578 | status = statusCode[status] if isinstance(status, str) else status 579 | 580 | messageData: Dict[str, Any] = { 581 | "statusCode": status, 582 | "headers": {"Content-Type": content_type}, 583 | } 584 | 585 | if cors: 586 | messageData["headers"]["Access-Control-Allow-Origin"] = "*" 587 | messageData["headers"]["Access-Control-Allow-Methods"] = ",".join( 588 | accepted_methods 589 | ) 590 | messageData["headers"]["Access-Control-Allow-Credentials"] = "true" 591 | 592 | if compression and compression in accepted_compression: 593 | messageData["headers"]["Content-Encoding"] = compression 594 | if isinstance(response_body, str): 595 | response_body = bytes(response_body, "utf-8") 596 | 597 | if compression == "gzip": 598 | gzip_compress = zlib.compressobj(9, zlib.DEFLATED, zlib.MAX_WBITS | 16) 599 | response_body = ( 600 | gzip_compress.compress(response_body) + gzip_compress.flush() 601 | ) 602 | elif compression == "zlib": 603 | zlib_compress = zlib.compressobj(9, zlib.DEFLATED, zlib.MAX_WBITS) 604 | response_body = ( 605 | zlib_compress.compress(response_body) + zlib_compress.flush() 606 | ) 607 | elif compression == "deflate": 608 | deflate_compress = zlib.compressobj(9, zlib.DEFLATED, -zlib.MAX_WBITS) 609 | response_body = ( 610 | deflate_compress.compress(response_body) + deflate_compress.flush() 611 | ) 612 | else: 613 | return self.response( 614 | "ERROR", 615 | "application/json", 616 | json.dumps( 617 | {"errorMessage": f"Unsupported compression mode: {compression}"} 618 | ), 619 | ) 620 | 621 | if ttl: 622 | messageData["headers"]["Cache-Control"] = ( 623 | f"max-age={ttl}" if status == 200 else "no-cache" 624 | ) 625 | elif cache_control: 626 | messageData["headers"]["Cache-Control"] = ( 627 | cache_control if status == 200 else "no-cache" 628 | ) 629 | 630 | if ( 631 | content_type in binary_types or not isinstance(response_body, str) 632 | ) and b64encode: 633 | messageData["isBase64Encoded"] = True 634 | messageData["body"] = base64.b64encode(response_body).decode() 635 | else: 636 | messageData["body"] = response_body 637 | 638 | return messageData 639 | 640 | def __call__(self, event, context): 641 | """Initialize route and handlers.""" 642 | self.log.debug(json.dumps(event, default=str)) 643 | 644 | self.event = event 645 | self.context = context 646 | 647 | # HACK: For an unknown reason some keys can have lower or upper case. 648 | # To make sure the app works well we cast all the keys to lowercase. 649 | headers = self.event.get("headers", {}) or {} 650 | self.event["headers"] = dict( 651 | (key.lower(), value) for key, value in headers.items() 652 | ) 653 | 654 | self.request_path = ApigwPath(self.event) 655 | if self.request_path.path is None: 656 | return self.response( 657 | "NOK", 658 | "application/json", 659 | json.dumps({"errorMessage": "Missing or invalid path"}), 660 | ) 661 | 662 | http_method = event["httpMethod"] 663 | route_entry = self._url_matching(self.request_path.path, http_method) 664 | if not route_entry: 665 | return self.response( 666 | "NOK", 667 | "application/json", 668 | json.dumps( 669 | { 670 | "errorMessage": "No view function for: {} - {}".format( 671 | http_method, self.request_path.path 672 | ) 673 | } 674 | ), 675 | ) 676 | 677 | request_params = event.get("queryStringParameters", {}) or {} 678 | if route_entry.token: 679 | if not self._validate_token(request_params.get("access_token")): 680 | return self.response( 681 | "ERROR", 682 | "application/json", 683 | json.dumps({"message": "Invalid access token"}), 684 | ) 685 | 686 | # remove access_token from kwargs 687 | request_params.pop("access_token", False) 688 | 689 | function_kwargs = self._get_matching_args(route_entry, self.request_path.path) 690 | function_kwargs.update(request_params.copy()) 691 | if http_method in ["POST", "PUT", "PATCH"] and event.get("body"): 692 | body = event["body"] 693 | if event.get("isBase64Encoded"): 694 | body = base64.b64decode(body).decode() 695 | function_kwargs.update(dict(body=body)) 696 | 697 | try: 698 | response = route_entry.endpoint(**function_kwargs) 699 | except Exception as err: 700 | self.log.error(str(err)) 701 | response = ( 702 | "ERROR", 703 | "application/json", 704 | json.dumps({"errorMessage": str(err)}), 705 | ) 706 | 707 | return self.response( 708 | response[0], 709 | response[1], 710 | response[2], 711 | cors=route_entry.cors, 712 | accepted_methods=route_entry.methods, 713 | accepted_compression=self.event["headers"].get("accept-encoding", ""), 714 | compression=route_entry.compression, 715 | b64encode=route_entry.b64encode, 716 | ttl=route_entry.ttl, 717 | cache_control=route_entry.cache_control, 718 | ) 719 | -------------------------------------------------------------------------------- /lambda_proxy/templates.py: -------------------------------------------------------------------------------- 1 | """ 2 | lambda-proxy api documents. 3 | 4 | Freely adapted from https://github.com/tiangolo/fastapi/blob/master/fastapi/openapi/docs.py 5 | """ 6 | 7 | 8 | def swagger( 9 | openapi_url: str, 10 | title: str, 11 | swagger_js_url: str = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@3/swagger-ui-bundle.js", 12 | swagger_css_url: str = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@3/swagger-ui.css", 13 | swagger_favicon_url: str = "https://fastapi.tiangolo.com/img/favicon.png", 14 | ) -> str: 15 | """Create swagger HTML document.""" 16 | html = f""" 17 | 18 | 19 | 20 | 21 | 22 | {title} 23 | 24 | 25 |
26 |
27 | 28 | 29 | 43 | 44 | 45 | """ 46 | return html 47 | 48 | 49 | def redoc( 50 | openapi_url: str, 51 | title: str, 52 | redoc_js_url: str = "https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js", 53 | redoc_favicon_url: str = "https://fastapi.tiangolo.com/img/favicon.png", 54 | ) -> str: 55 | """Create redoc HTML document.""" 56 | html = f""" 57 | 58 | 59 | 60 | {title} 61 | 62 | 63 | 64 | 65 | 66 | 69 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | """ 82 | return html 83 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Setup lambda_proxy.""" 2 | 3 | from setuptools import setup, find_packages 4 | 5 | with open("README.md") as f: 6 | readme = f.read() 7 | 8 | 9 | extra_reqs = {"test": ["pytest", "pytest-cov", "mock"]} 10 | 11 | 12 | setup( 13 | name="lambda-proxy", 14 | version="5.2.1", 15 | description=u"Simple AWS Lambda proxy to handle API Gateway request", 16 | long_description=readme, 17 | long_description_content_type="text/markdown", 18 | python_requires=">=3", 19 | classifiers=[ 20 | "Intended Audience :: Information Technology", 21 | "Intended Audience :: Science/Research", 22 | "License :: OSI Approved :: BSD License", 23 | "Programming Language :: Python :: 3.6", 24 | "Programming Language :: Python :: 3.7", 25 | ], 26 | keywords="AWS-Lambda API-Gateway Request Proxy", 27 | author=u"Vincent Sarago", 28 | author_email="vincent.sarago@gmail.com", 29 | url="https://github.com/vincentsarago/lambda-proxy", 30 | license="BSD", 31 | packages=find_packages(exclude=["ez_setup", "examples", "tests"]), 32 | include_package_data=True, 33 | zip_safe=False, 34 | extras_require=extra_reqs, 35 | ) 36 | -------------------------------------------------------------------------------- /tests/fixtures/openapi.json: -------------------------------------------------------------------------------- 1 | {"openapi": "3.0.2", "info": {"title": "test", "version": "0.0.1"}, "components": {"securitySchemes": {"access_token": {"type": "apiKey", "description": "Simple token authentification", "in": "query", "name": "access_token"}}}, "paths": {"/openapi.json": {"get": {"tags": ["documentation"], "description": "Return OpenAPI json.", "responses": {"400": {"description": "Not found"}, "500": {"description": "Internal error"}}, "operationId": "/openapi.json"}}, "/docs": {"get": {"tags": ["documentation"], "description": "Display Swagger HTML UI.", "responses": {"400": {"description": "Not found"}, "500": {"description": "Internal error"}}, "operationId": "/docs"}}, "/redoc": {"get": {"tags": ["documentation"], "description": "Display Redoc HTML UI.", "responses": {"400": {"description": "Not found"}, "500": {"description": "Internal error"}}, "operationId": "/redoc"}}, "/test": {"post": {"description": "Return something.", "parameters": [], "responses": {"400": {"description": "Not found"}, "500": {"description": "Internal error"}}, "operationId": "/test", "requestBody": {"description": "Body", "content": {"*/*": {}}, "required": true}}}, "/{user}": {"get": {"tags": ["users"], "description": "a route", "parameters": [{"name": "user", "in": "path", "schema": {"type": "string"}, "required": true}], "responses": {"400": {"description": "Not found"}, "500": {"description": "Internal error"}}, "operationId": "/{user}"}}, "/{num}": {"get": {"description": "Return something.", "security": [{"access_token": []}], "parameters": [{"name": "num", "in": "path", "schema": {"type": "integer"}, "required": true}], "responses": {"400": {"description": "Not found"}, "500": {"description": "Internal error"}}, "operationId": "/{num}"}}, "/{user}/{num}": {"get": {"description": "Return something.", "parameters": [{"name": "user", "in": "path", "schema": {"type": "string"}, "required": true}, {"name": "num", "in": "path", "schema": {"type": "string"}, "required": true}, {"name": "evt", "in": "query", "schema": {"format": "string"}, "required": true}, {"name": "ctx", "in": "query", "schema": {"format": "string"}, "required": true}], "responses": {"400": {"description": "Not found"}, "500": {"description": "Internal error"}}, "operationId": "/{user}/{num}"}}}} -------------------------------------------------------------------------------- /tests/fixtures/openapi_apigw.json: -------------------------------------------------------------------------------- 1 | {"openapi": "3.0.2", "info": {"title": "test", "version": "0.0.1"}, "components": {"securitySchemes": {"access_token": {"type": "apiKey", "description": "Simple token authentification", "in": "query", "name": "access_token"}}}, "paths": {"/production/openapi.json": {"get": {"tags": ["documentation"], "description": "Return OpenAPI json.", "responses": {"400": {"description": "Not found"}, "500": {"description": "Internal error"}}, "operationId": "/openapi.json"}}, "/production/docs": {"get": {"tags": ["documentation"], "description": "Display Swagger HTML UI.", "responses": {"400": {"description": "Not found"}, "500": {"description": "Internal error"}}, "operationId": "/docs"}}, "/production/redoc": {"get": {"tags": ["documentation"], "description": "Display Redoc HTML UI.", "responses": {"400": {"description": "Not found"}, "500": {"description": "Internal error"}}, "operationId": "/redoc"}}, "/production/test": {"post": {"description": "Return something.", "parameters": [], "responses": {"400": {"description": "Not found"}, "500": {"description": "Internal error"}}, "operationId": "/test", "requestBody": {"description": "Body", "content": {"*/*": {}}, "required": true}}}, "/production/{user}": {"get": {"tags": ["users"], "description": "a route", "parameters": [{"name": "user", "in": "path", "schema": {"type": "string"}, "required": true}], "responses": {"400": {"description": "Not found"}, "500": {"description": "Internal error"}}, "operationId": "/{user}"}}, "/production/{num}": {"get": {"description": "Return something.", "security": [{"access_token": []}], "parameters": [{"name": "num", "in": "path", "schema": {"type": "integer"}, "required": true}], "responses": {"400": {"description": "Not found"}, "500": {"description": "Internal error"}}, "operationId": "/{num}"}}, "/production/{user}/{num}": {"get": {"description": "Return something.", "parameters": [{"name": "user", "in": "path", "schema": {"type": "string"}, "required": true}, {"name": "num", "in": "path", "schema": {"type": "string"}, "required": true}, {"name": "evt", "in": "query", "schema": {"format": "string"}, "required": true}, {"name": "ctx", "in": "query", "schema": {"format": "string"}, "required": true}], "responses": {"400": {"description": "Not found"}, "500": {"description": "Internal error"}}, "operationId": "/{user}/{num}"}}}} -------------------------------------------------------------------------------- /tests/fixtures/openapi_custom.json: -------------------------------------------------------------------------------- 1 | {"openapi": "3.0.2", "info": {"title": "test", "version": "0.0.1"}, "components": {"securitySchemes": {"access_token": {"type": "apiKey", "description": "Simple token authentification", "in": "query", "name": "access_token"}}}, "paths": {"/api/openapi.json": {"get": {"tags": ["documentation"], "description": "Return OpenAPI json.", "responses": {"400": {"description": "Not found"}, "500": {"description": "Internal error"}}, "operationId": "/openapi.json"}}, "/api/docs": {"get": {"tags": ["documentation"], "description": "Display Swagger HTML UI.", "responses": {"400": {"description": "Not found"}, "500": {"description": "Internal error"}}, "operationId": "/docs"}}, "/api/redoc": {"get": {"tags": ["documentation"], "description": "Display Redoc HTML UI.", "responses": {"400": {"description": "Not found"}, "500": {"description": "Internal error"}}, "operationId": "/redoc"}}, "/api/test": {"post": {"description": "Return something.", "parameters": [], "responses": {"400": {"description": "Not found"}, "500": {"description": "Internal error"}}, "operationId": "/test", "requestBody": {"description": "Body", "content": {"*/*": {}}, "required": true}}}, "/api/{user}": {"get": {"tags": ["users"], "description": "a route", "parameters": [{"name": "user", "in": "path", "schema": {"type": "string"}, "required": true}], "responses": {"400": {"description": "Not found"}, "500": {"description": "Internal error"}}, "operationId": "/{user}"}}, "/api/{num}": {"get": {"description": "Return something.", "security": [{"access_token": []}], "parameters": [{"name": "num", "in": "path", "schema": {"type": "integer"}, "required": true}], "responses": {"400": {"description": "Not found"}, "500": {"description": "Internal error"}}, "operationId": "/{num}"}}, "/api/{user}/{num}": {"get": {"description": "Return something.", "parameters": [{"name": "user", "in": "path", "schema": {"type": "string"}, "required": true}, {"name": "num", "in": "path", "schema": {"type": "string"}, "required": true}, {"name": "evt", "in": "query", "schema": {"format": "string"}, "required": true}, {"name": "ctx", "in": "query", "schema": {"format": "string"}, "required": true}], "responses": {"400": {"description": "Not found"}, "500": {"description": "Internal error"}}, "operationId": "/{user}/{num}"}}}} -------------------------------------------------------------------------------- /tests/test_proxy.py: -------------------------------------------------------------------------------- 1 | """Test lambda-proxy.""" 2 | 3 | from typing import Dict, Tuple 4 | 5 | import os 6 | import json 7 | import zlib 8 | import base64 9 | 10 | import pytest 11 | from mock import Mock 12 | 13 | from lambda_proxy import proxy 14 | 15 | json_api = os.path.join(os.path.dirname(__file__), "fixtures", "openapi.json") 16 | with open(json_api, "r") as f: 17 | openapi_content = json.loads(f.read()) 18 | 19 | json_api_custom = os.path.join( 20 | os.path.dirname(__file__), "fixtures", "openapi_custom.json" 21 | ) 22 | with open(json_api_custom, "r") as f: 23 | openapi_custom_content = json.loads(f.read()) 24 | 25 | json_apigw = os.path.join(os.path.dirname(__file__), "fixtures", "openapi_apigw.json") 26 | with open(json_apigw, "r") as f: 27 | openapi_apigw_content = json.loads(f.read()) 28 | 29 | funct = Mock(__name__="Mock") 30 | 31 | 32 | def test_value_converters(): 33 | """Convert convert value to correct type.""" 34 | pathArg = "" 35 | assert "123" == proxy._converters("123", pathArg) 36 | 37 | pathArg = "" 38 | assert 123 == proxy._converters("123", pathArg) 39 | 40 | pathArg = "" 41 | assert 123.0 == proxy._converters("123", pathArg) 42 | 43 | pathArg = "" 44 | assert "f5c21e12-8317-11e9-bf96-2e2ca3acb545" == proxy._converters( 45 | "f5c21e12-8317-11e9-bf96-2e2ca3acb545", pathArg 46 | ) 47 | 48 | pathArg = "" 49 | assert "123" == proxy._converters("123", pathArg) 50 | 51 | 52 | def test_path_to_regex_convert(): 53 | """Convert route path to regex.""" 54 | path = "/jqtrde///////" 55 | assert ( 56 | "^/jqtrde/([a-zA-Z0-9_]+)/([a-zA-Z0-9_]+)/([0-9]+)/([+-]?[0-9]+.[0-9]+)/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/([A-Z0-9]{5})/([a-z]{1})$" 57 | == proxy._path_to_regex(path) 58 | ) 59 | 60 | 61 | def test_path_to_openapi_converters(): 62 | """Convert proxy path to openapi path.""" 63 | path = "//-" 64 | assert "/{num}/{test}-{var}" == proxy._path_to_openapi(path) 65 | 66 | 67 | def test_RouteEntry_default(): 68 | """Should work as expected.""" 69 | route = proxy.RouteEntry(funct, "/endpoint/test/") 70 | assert route.endpoint == funct 71 | assert route.methods == ["GET"] 72 | assert not route.cors 73 | assert not route.token 74 | assert not route.compression 75 | assert not route.b64encode 76 | 77 | 78 | def test_RouteEntry_Options(): 79 | """Should work as expected.""" 80 | route = proxy.RouteEntry( 81 | funct, 82 | "/endpoint/test/", 83 | ["POST"], 84 | cors=True, 85 | token="Yo", 86 | payload_compression_method="deflate", 87 | binary_b64encode=True, 88 | ) 89 | assert route.endpoint == funct 90 | assert route.methods == ["POST"] 91 | assert route.cors 92 | assert route.token == "Yo" 93 | assert route.compression == "deflate" 94 | assert route.b64encode 95 | 96 | 97 | def test_RouteEntry_invalidCompression(): 98 | """Should work as expected.""" 99 | with pytest.raises(ValueError): 100 | proxy.RouteEntry( 101 | funct, 102 | "my-function", 103 | "/endpoint/test/", 104 | payload_compression_method="nope", 105 | ) 106 | 107 | 108 | def test_API_init(): 109 | """Should work as expected.""" 110 | app = proxy.API(name="test") 111 | assert app.name == "test" 112 | assert len(list(app.routes)) == 3 113 | assert not app.debug 114 | assert app.log.getEffectiveLevel() == 40 # ERROR 115 | 116 | # Clear logger handlers 117 | for h in app.log.handlers: 118 | app.log.removeHandler(h) 119 | 120 | 121 | def test_API_noDocs(): 122 | """Do not set default documentation routes.""" 123 | app = proxy.API(name="test", add_docs=False) 124 | assert app.name == "test" 125 | assert len(list(app.routes)) == 0 126 | assert not app.debug 127 | assert app.log.getEffectiveLevel() == 40 # ERROR 128 | 129 | # Clear logger handlers 130 | for h in app.log.handlers: 131 | app.log.removeHandler(h) 132 | 133 | 134 | def test_API_noLog(): 135 | """Should work as expected.""" 136 | app = proxy.API(name="test", configure_logs=False) 137 | assert app.name == "test" 138 | assert not app.debug 139 | assert app.log 140 | 141 | # Clear logger handlers 142 | for h in app.log.handlers: 143 | app.log.removeHandler(h) 144 | 145 | 146 | def test_API_logDebug(): 147 | """Should work as expected.""" 148 | app = proxy.API(name="test", debug=True) 149 | assert app.log.getEffectiveLevel() == 10 # DEBUG 150 | 151 | # Clear logger handlers 152 | for h in app.log.handlers: 153 | app.log.removeHandler(h) 154 | 155 | 156 | def test_API_addRoute(): 157 | """Add and parse route.""" 158 | app = proxy.API(name="test") 159 | assert len(list(app.routes)) == 3 160 | 161 | app._add_route("/endpoint/test/", funct, methods=["GET"], cors=True, token="yo") 162 | assert app.routes 163 | 164 | with pytest.raises(ValueError): 165 | app._add_route("/endpoint/test/", funct, methods=["GET"], cors=True) 166 | 167 | with pytest.raises(TypeError): 168 | app._add_route("/endpoint/test/", funct, methods=["GET"], c=True) 169 | 170 | # Clear logger handlers 171 | for h in app.log.handlers: 172 | app.log.removeHandler(h) 173 | 174 | 175 | def test_proxy_API(): 176 | """Add and parse route.""" 177 | app = proxy.API(name="test") 178 | funct = Mock(__name__="Mock", return_value=("OK", "text/plain", "heyyyy")) 179 | app._add_route("/test//", funct, methods=["GET"], cors=True) 180 | 181 | event = { 182 | "path": "/test/remote/pixel", 183 | "httpMethod": "GET", 184 | "headers": {}, 185 | "queryStringParameters": {}, 186 | } 187 | resp = { 188 | "body": "heyyyy", 189 | "headers": { 190 | "Access-Control-Allow-Credentials": "true", 191 | "Access-Control-Allow-Methods": "GET", 192 | "Access-Control-Allow-Origin": "*", 193 | "Content-Type": "text/plain", 194 | }, 195 | "statusCode": 200, 196 | } 197 | res = app(event, {}) 198 | assert res == resp 199 | funct.assert_called_with(user="remote", name="pixel") 200 | 201 | 202 | def test_proxy_intStatus_API(): 203 | """Add and parse route.""" 204 | app = proxy.API(name="test") 205 | funct = Mock(__name__="Mock", return_value=(204, "text/plain", "heyyyy")) 206 | app._add_route("/test", funct, methods=["GET"], cors=True) 207 | 208 | event = { 209 | "path": "/test", 210 | "httpMethod": "GET", 211 | "headers": {}, 212 | "queryStringParameters": {}, 213 | } 214 | resp = { 215 | "body": "heyyyy", 216 | "headers": { 217 | "Access-Control-Allow-Credentials": "true", 218 | "Access-Control-Allow-Methods": "GET", 219 | "Access-Control-Allow-Origin": "*", 220 | "Content-Type": "text/plain", 221 | }, 222 | "statusCode": 204, 223 | } 224 | res = app(event, {}) 225 | assert res == resp 226 | 227 | 228 | def test_proxy_APIpath(): 229 | """Add and parse route.""" 230 | app = proxy.API(name="test") 231 | funct = Mock(__name__="Mock", return_value=("OK", "text/plain", "heyyyy")) 232 | app._add_route("/test//", funct, methods=["GET"], cors=True) 233 | 234 | event = { 235 | "resource": "/", 236 | "path": "/test/remote/pixel", 237 | "httpMethod": "GET", 238 | "headers": {}, 239 | "queryStringParameters": {}, 240 | } 241 | resp = { 242 | "body": "heyyyy", 243 | "headers": { 244 | "Access-Control-Allow-Credentials": "true", 245 | "Access-Control-Allow-Methods": "GET", 246 | "Access-Control-Allow-Origin": "*", 247 | "Content-Type": "text/plain", 248 | }, 249 | "statusCode": 200, 250 | } 251 | res = app(event, {}) 252 | assert res == resp 253 | funct.assert_called_with(user="remote", name="pixel") 254 | 255 | 256 | def test_proxy_APIpathProxy(): 257 | """Add and parse route.""" 258 | app = proxy.API(name="test") 259 | funct = Mock(__name__="Mock", return_value=("OK", "text/plain", "heyyyy")) 260 | app._add_route("/test//", funct, methods=["GET"], cors=True) 261 | 262 | event = { 263 | "resource": "{something+}", 264 | "pathParameters": {"something": "test/remote/pixel"}, 265 | "path": "/test/remote/pixel", 266 | "httpMethod": "GET", 267 | "headers": {}, 268 | "queryStringParameters": {}, 269 | } 270 | resp = { 271 | "body": "heyyyy", 272 | "headers": { 273 | "Access-Control-Allow-Credentials": "true", 274 | "Access-Control-Allow-Methods": "GET", 275 | "Access-Control-Allow-Origin": "*", 276 | "Content-Type": "text/plain", 277 | }, 278 | "statusCode": 200, 279 | } 280 | res = app(event, {}) 281 | assert res == resp 282 | funct.assert_called_with(user="remote", name="pixel") 283 | 284 | 285 | def test_proxy_APIpathCustomDomain(): 286 | """Add and parse route.""" 287 | app = proxy.API(name="test") 288 | funct = Mock(__name__="Mock", return_value=("OK", "text/plain", "heyyyy")) 289 | app._add_route("/test//", funct, methods=["GET"], cors=True) 290 | 291 | event = { 292 | "resource": "/{something+}", 293 | "pathParameters": {"something": "test/remote/pixel"}, 294 | "path": "/myapi/test/remote/pixel", 295 | "httpMethod": "GET", 296 | "headers": {}, 297 | "queryStringParameters": {}, 298 | } 299 | resp = { 300 | "body": "heyyyy", 301 | "headers": { 302 | "Access-Control-Allow-Credentials": "true", 303 | "Access-Control-Allow-Methods": "GET", 304 | "Access-Control-Allow-Origin": "*", 305 | "Content-Type": "text/plain", 306 | }, 307 | "statusCode": 200, 308 | } 309 | res = app(event, {}) 310 | assert res == resp 311 | funct.assert_called_with(user="remote", name="pixel") 312 | 313 | 314 | def test_ttl(): 315 | """Add and parse route.""" 316 | app = proxy.API(name="test") 317 | funct = Mock(__name__="Mock", return_value=("OK", "text/plain", "heyyyy")) 318 | with pytest.warns(DeprecationWarning): 319 | app._add_route( 320 | "/test//", funct, methods=["GET"], cors=True, ttl=3600 321 | ) 322 | funct_error = Mock( 323 | __name__="Mock", return_value=("NOK", "text/plain", "heyyyy") 324 | ) 325 | app._add_route("/yo", funct_error, methods=["GET"], cors=True, ttl=3600) 326 | 327 | event = { 328 | "path": "/test/remote/pixel", 329 | "httpMethod": "GET", 330 | "headers": {}, 331 | "queryStringParameters": {}, 332 | } 333 | resp = { 334 | "body": "heyyyy", 335 | "headers": { 336 | "Access-Control-Allow-Credentials": "true", 337 | "Access-Control-Allow-Methods": "GET", 338 | "Access-Control-Allow-Origin": "*", 339 | "Content-Type": "text/plain", 340 | "Cache-Control": "max-age=3600", 341 | }, 342 | "statusCode": 200, 343 | } 344 | res = app(event, {}) 345 | assert res == resp 346 | funct.assert_called_with(user="remote", name="pixel") 347 | 348 | event = { 349 | "path": "/yo", 350 | "httpMethod": "GET", 351 | "headers": {}, 352 | "queryStringParameters": {}, 353 | } 354 | res = app(event, {}) 355 | assert res["headers"]["Cache-Control"] == "no-cache" 356 | 357 | 358 | def test_cache_control(): 359 | """Add and parse route.""" 360 | app = proxy.API(name="test") 361 | funct = Mock(__name__="Mock", return_value=("OK", "text/plain", "heyyyy")) 362 | app._add_route( 363 | "/test//", 364 | funct, 365 | methods=["GET"], 366 | cors=True, 367 | cache_control="public,max-age=3600", 368 | ) 369 | funct_error = Mock(__name__="Mock", return_value=("NOK", "text/plain", "heyyyy")) 370 | app._add_route( 371 | "/yo", 372 | funct_error, 373 | methods=["GET"], 374 | cors=True, 375 | cache_control="public,max-age=3600", 376 | ) 377 | 378 | event = { 379 | "path": "/test/remote/pixel", 380 | "httpMethod": "GET", 381 | "headers": {}, 382 | "queryStringParameters": {}, 383 | } 384 | resp = { 385 | "body": "heyyyy", 386 | "headers": { 387 | "Access-Control-Allow-Credentials": "true", 388 | "Access-Control-Allow-Methods": "GET", 389 | "Access-Control-Allow-Origin": "*", 390 | "Content-Type": "text/plain", 391 | "Cache-Control": "public,max-age=3600", 392 | }, 393 | "statusCode": 200, 394 | } 395 | res = app(event, {}) 396 | assert res == resp 397 | funct.assert_called_with(user="remote", name="pixel") 398 | 399 | event = { 400 | "path": "/yo", 401 | "httpMethod": "GET", 402 | "headers": {}, 403 | "queryStringParameters": {}, 404 | } 405 | res = app(event, {}) 406 | assert res["headers"]["Cache-Control"] == "no-cache" 407 | 408 | 409 | def test_querystringNull(): 410 | """Add and parse route.""" 411 | app = proxy.API(name="test") 412 | funct = Mock(__name__="Mock", return_value=("OK", "text/plain", "heyyyy")) 413 | app._add_route("/test/", funct, methods=["GET"], cors=True) 414 | 415 | event = { 416 | "path": "/test/remotepixel", 417 | "httpMethod": "GET", 418 | "headers": {}, 419 | "queryStringParameters": None, 420 | } 421 | resp = { 422 | "body": "heyyyy", 423 | "headers": { 424 | "Access-Control-Allow-Credentials": "true", 425 | "Access-Control-Allow-Methods": "GET", 426 | "Access-Control-Allow-Origin": "*", 427 | "Content-Type": "text/plain", 428 | }, 429 | "statusCode": 200, 430 | } 431 | res = app(event, {}) 432 | assert res == resp 433 | funct.assert_called_with(user="remotepixel") 434 | 435 | 436 | def test_headersNull(): 437 | """Add and parse route.""" 438 | app = proxy.API(name="test") 439 | funct = Mock(__name__="Mock", return_value=("OK", "text/plain", "heyyyy")) 440 | app._add_route("/test/", funct, methods=["GET"], cors=True) 441 | 442 | event = { 443 | "path": "/test/remotepixel", 444 | "httpMethod": "GET", 445 | "headers": None, 446 | "queryStringParameters": {}, 447 | } 448 | resp = { 449 | "body": "heyyyy", 450 | "headers": { 451 | "Access-Control-Allow-Credentials": "true", 452 | "Access-Control-Allow-Methods": "GET", 453 | "Access-Control-Allow-Origin": "*", 454 | "Content-Type": "text/plain", 455 | }, 456 | "statusCode": 200, 457 | } 458 | res = app(event, {}) 459 | assert res == resp 460 | funct.assert_called_with(user="remotepixel") 461 | 462 | 463 | def test_API_encoding(): 464 | """Test b64 encoding.""" 465 | app = proxy.API(name="test") 466 | 467 | body = b"thisisafakeencodedjpeg" 468 | b64body = base64.b64encode(body).decode() 469 | 470 | funct = Mock(__name__="Mock", return_value=("OK", "image/jpeg", body)) 471 | app._add_route("/test/.jpg", funct, methods=["GET"], cors=True) 472 | 473 | event = { 474 | "path": "/test/remotepixel.jpg", 475 | "httpMethod": "GET", 476 | "headers": {}, 477 | "queryStringParameters": {}, 478 | } 479 | resp = { 480 | "body": body, 481 | "headers": { 482 | "Access-Control-Allow-Credentials": "true", 483 | "Access-Control-Allow-Methods": "GET", 484 | "Access-Control-Allow-Origin": "*", 485 | "Content-Type": "image/jpeg", 486 | }, 487 | "statusCode": 200, 488 | } 489 | res = app(event, {}) 490 | assert res == resp 491 | 492 | app._add_route( 493 | "/test_encode/.jpg", 494 | funct, 495 | methods=["GET"], 496 | cors=True, 497 | binary_b64encode=True, 498 | ) 499 | event = { 500 | "path": "/test_encode/remotepixel.jpg", 501 | "httpMethod": "GET", 502 | "headers": {}, 503 | "queryStringParameters": {}, 504 | } 505 | resp = { 506 | "body": b64body, 507 | "headers": { 508 | "Access-Control-Allow-Credentials": "true", 509 | "Access-Control-Allow-Methods": "GET", 510 | "Access-Control-Allow-Origin": "*", 511 | "Content-Type": "image/jpeg", 512 | }, 513 | "isBase64Encoded": True, 514 | "statusCode": 200, 515 | } 516 | res = app(event, {}) 517 | assert res == resp 518 | 519 | 520 | def test_API_compression(): 521 | """Test compression and base64.""" 522 | body = b"thisisafakeencodedjpeg" 523 | gzip_compress = zlib.compressobj(9, zlib.DEFLATED, zlib.MAX_WBITS | 16) 524 | gzbody = gzip_compress.compress(body) + gzip_compress.flush() 525 | b64gzipbody = base64.b64encode(gzbody).decode() 526 | 527 | app = proxy.API(name="test") 528 | funct = Mock(__name__="Mock", return_value=("OK", "image/jpeg", body)) 529 | app._add_route( 530 | "/test_compress/.jpg", 531 | funct, 532 | methods=["GET"], 533 | cors=True, 534 | payload_compression_method="gzip", 535 | ) 536 | 537 | # Should compress because "Accept-Encoding" is in header 538 | event = { 539 | "path": "/test_compress/remotepixel.jpg", 540 | "httpMethod": "GET", 541 | "headers": {"Accept-Encoding": "gzip, deflate"}, 542 | "queryStringParameters": {}, 543 | } 544 | resp = { 545 | "body": gzbody, 546 | "headers": { 547 | "Access-Control-Allow-Credentials": "true", 548 | "Access-Control-Allow-Methods": "GET", 549 | "Access-Control-Allow-Origin": "*", 550 | "Content-Encoding": "gzip", 551 | "Content-Type": "image/jpeg", 552 | }, 553 | "statusCode": 200, 554 | } 555 | res = app(event, {}) 556 | assert res == resp 557 | 558 | # Should not compress because "Accept-Encoding" is missing in header 559 | event = { 560 | "path": "/test_compress/remotepixel.jpg", 561 | "httpMethod": "GET", 562 | "headers": {}, 563 | "queryStringParameters": {}, 564 | } 565 | resp = { 566 | "body": body, 567 | "headers": { 568 | "Access-Control-Allow-Credentials": "true", 569 | "Access-Control-Allow-Methods": "GET", 570 | "Access-Control-Allow-Origin": "*", 571 | "Content-Type": "image/jpeg", 572 | }, 573 | "statusCode": 200, 574 | } 575 | res = app(event, {}) 576 | assert res == resp 577 | 578 | # Should compress and encode to base64 579 | app._add_route( 580 | "/test_compress_b64/.jpg", 581 | funct, 582 | methods=["GET"], 583 | cors=True, 584 | payload_compression_method="gzip", 585 | binary_b64encode=True, 586 | ) 587 | event = { 588 | "path": "/test_compress_b64/remotepixel.jpg", 589 | "httpMethod": "GET", 590 | "headers": {"Accept-Encoding": "gzip, deflate"}, 591 | "queryStringParameters": {}, 592 | } 593 | resp = { 594 | "body": b64gzipbody, 595 | "headers": { 596 | "Access-Control-Allow-Credentials": "true", 597 | "Access-Control-Allow-Methods": "GET", 598 | "Access-Control-Allow-Origin": "*", 599 | "Content-Encoding": "gzip", 600 | "Content-Type": "image/jpeg", 601 | }, 602 | "isBase64Encoded": True, 603 | "statusCode": 200, 604 | } 605 | res = app(event, {}) 606 | assert res == resp 607 | 608 | funct = Mock( 609 | __name__="Mock", 610 | return_value=("OK", "application/json", json.dumps({"test": 0})), 611 | ) 612 | # Should compress and encode to base64 613 | app._add_route( 614 | "/test_compress_b64/.json", 615 | funct, 616 | methods=["GET"], 617 | cors=True, 618 | payload_compression_method="gzip", 619 | binary_b64encode=True, 620 | ) 621 | event = { 622 | "path": "/test_compress_b64/remotepixel.json", 623 | "httpMethod": "GET", 624 | "headers": {"Accept-Encoding": "gzip, deflate"}, 625 | "queryStringParameters": {}, 626 | } 627 | 628 | body = bytes(json.dumps({"test": 0}), "utf-8") 629 | gzip_compress = zlib.compressobj(9, zlib.DEFLATED, zlib.MAX_WBITS | 16) 630 | gzbody = gzip_compress.compress(body) + gzip_compress.flush() 631 | b64gzipbody = base64.b64encode(gzbody).decode() 632 | resp = { 633 | "body": b64gzipbody, 634 | "headers": { 635 | "Access-Control-Allow-Credentials": "true", 636 | "Access-Control-Allow-Methods": "GET", 637 | "Access-Control-Allow-Origin": "*", 638 | "Content-Encoding": "gzip", 639 | "Content-Type": "application/json", 640 | }, 641 | "isBase64Encoded": True, 642 | "statusCode": 200, 643 | } 644 | res = app(event, {}) 645 | assert res == resp 646 | 647 | event = { 648 | "path": "/test_compress_b64/remotepixel.json", 649 | "httpMethod": "GET", 650 | "headers": {}, 651 | "queryStringParameters": {}, 652 | } 653 | 654 | resp = { 655 | "body": json.dumps({"test": 0}), 656 | "headers": { 657 | "Access-Control-Allow-Credentials": "true", 658 | "Access-Control-Allow-Methods": "GET", 659 | "Access-Control-Allow-Origin": "*", 660 | "Content-Type": "application/json", 661 | }, 662 | "statusCode": 200, 663 | } 664 | res = app(event, {}) 665 | assert res == resp 666 | 667 | 668 | def test_API_otherCompression(): 669 | """Test other compression.""" 670 | 671 | body = b"thisisafakeencodedjpeg" 672 | zlib_compress = zlib.compressobj(9, zlib.DEFLATED, zlib.MAX_WBITS) 673 | deflate_compress = zlib.compressobj(9, zlib.DEFLATED, -zlib.MAX_WBITS) 674 | zlibbody = zlib_compress.compress(body) + zlib_compress.flush() 675 | deflbody = deflate_compress.compress(body) + deflate_compress.flush() 676 | 677 | app = proxy.API(name="test") 678 | funct = Mock(__name__="Mock", return_value=("OK", "image/jpeg", body)) 679 | app._add_route( 680 | "/test_deflate/.jpg", 681 | funct, 682 | methods=["GET"], 683 | cors=True, 684 | payload_compression_method="deflate", 685 | ) 686 | app._add_route( 687 | "/test_zlib/.jpg", 688 | funct, 689 | methods=["GET"], 690 | cors=True, 691 | payload_compression_method="zlib", 692 | ) 693 | 694 | # Zlib 695 | event = { 696 | "path": "/test_zlib/remotepixel.jpg", 697 | "httpMethod": "GET", 698 | "headers": {"Accept-Encoding": "zlib, gzip, deflate"}, 699 | "queryStringParameters": {}, 700 | } 701 | resp = { 702 | "body": zlibbody, 703 | "headers": { 704 | "Access-Control-Allow-Credentials": "true", 705 | "Access-Control-Allow-Methods": "GET", 706 | "Access-Control-Allow-Origin": "*", 707 | "Content-Encoding": "zlib", 708 | "Content-Type": "image/jpeg", 709 | }, 710 | "statusCode": 200, 711 | } 712 | res = app(event, {}) 713 | assert res == resp 714 | 715 | # Deflate 716 | event = { 717 | "path": "/test_deflate/remotepixel.jpg", 718 | "httpMethod": "GET", 719 | "headers": {"Accept-Encoding": "zlib, gzip, deflate"}, 720 | "queryStringParameters": {}, 721 | } 722 | resp = { 723 | "body": deflbody, 724 | "headers": { 725 | "Access-Control-Allow-Credentials": "true", 726 | "Access-Control-Allow-Methods": "GET", 727 | "Access-Control-Allow-Origin": "*", 728 | "Content-Encoding": "deflate", 729 | "Content-Type": "image/jpeg", 730 | }, 731 | "statusCode": 200, 732 | } 733 | res = app(event, {}) 734 | assert res == resp 735 | 736 | 737 | def test_API_routeURL(): 738 | """Should catch invalid route and parse valid args.""" 739 | app = proxy.API(name="test") 740 | funct = Mock(__name__="Mock", return_value=("OK", "text/plain", "heyyyy")) 741 | app._add_route("/test/", funct, methods=["GET"], cors=True) 742 | 743 | event = { 744 | "route": "/users/remotepixel", 745 | "httpMethod": "GET", 746 | "headers": {}, 747 | "queryStringParameters": {}, 748 | } 749 | resp = { 750 | "body": '{"errorMessage": "Missing or invalid path"}', 751 | "headers": {"Content-Type": "application/json"}, 752 | "statusCode": 400, 753 | } 754 | res = app(event, {}) 755 | assert res == resp 756 | 757 | event = { 758 | "path": "/users/remotepixel", 759 | "httpMethod": "GET", 760 | "headers": {}, 761 | "queryStringParameters": {}, 762 | } 763 | resp = { 764 | "body": '{"errorMessage": "No view function for: GET - /users/remotepixel"}', 765 | "headers": {"Content-Type": "application/json"}, 766 | "statusCode": 400, 767 | } 768 | res = app(event, {}) 769 | assert res == resp 770 | 771 | event = { 772 | "path": "/test/remotepixel", 773 | "httpMethod": "POST", 774 | "headers": {}, 775 | "queryStringParameters": {}, 776 | } 777 | resp = { 778 | "body": '{"errorMessage": "No view function for: POST - /test/remotepixel"}', 779 | "headers": {"Content-Type": "application/json"}, 780 | "statusCode": 400, 781 | } 782 | res = app(event, {}) 783 | assert res == resp 784 | 785 | event = { 786 | "path": "/users/remotepixel", 787 | "httpMethod": "GET", 788 | "headers": {}, 789 | "queryStringParameters": {}, 790 | } 791 | resp = { 792 | "body": '{"errorMessage": "No view function for: GET - /users/remotepixel"}', 793 | "headers": {"Content-Type": "application/json"}, 794 | "statusCode": 400, 795 | } 796 | res = app(event, {}) 797 | assert res == resp 798 | 799 | event = { 800 | "path": "/test/users/remotepixel", 801 | "httpMethod": "GET", 802 | "headers": {}, 803 | "queryStringParameters": {}, 804 | } 805 | resp = { 806 | "body": '{"errorMessage": "No view function for: GET - /test/users/remotepixel"}', 807 | "headers": {"Content-Type": "application/json"}, 808 | "statusCode": 400, 809 | } 810 | res = app(event, {}) 811 | assert res == resp 812 | 813 | funct = Mock(__name__="Mock", return_value=("OK", "text/plain", "heyyyy")) 814 | app._add_route( 815 | "/test////.", 816 | funct, 817 | methods=["GET"], 818 | cors=True, 819 | ) 820 | 821 | event = { 822 | "path": "/test/remotepixel/6b0d1f74-8f81-11e8-83fd-6a0003389b00/1/-1.0.jpeg", 823 | "httpMethod": "GET", 824 | "headers": {}, 825 | "queryStringParameters": {}, 826 | } 827 | resp = { 828 | "body": "heyyyy", 829 | "headers": { 830 | "Access-Control-Allow-Credentials": "true", 831 | "Access-Control-Allow-Methods": "GET", 832 | "Access-Control-Allow-Origin": "*", 833 | "Content-Type": "text/plain", 834 | }, 835 | "statusCode": 200, 836 | } 837 | res = app(event, {}) 838 | assert res == resp 839 | funct.assert_called_with( 840 | v="remotepixel", 841 | uuid="6b0d1f74-8f81-11e8-83fd-6a0003389b00", 842 | z=1, 843 | x=-1.0, 844 | ext="jpeg", 845 | ) 846 | 847 | # Clear logger handlers 848 | for h in app.log.handlers: 849 | app.log.removeHandler(h) 850 | 851 | 852 | def test_API_routeToken(monkeypatch): 853 | """Validate tokens.""" 854 | monkeypatch.setenv("TOKEN", "yo") 855 | 856 | app = proxy.API(name="test") 857 | funct = Mock(__name__="Mock", return_value=("OK", "text/plain", "heyyyy")) 858 | app._add_route("/test/", funct, methods=["GET"], cors=True, token=True) 859 | 860 | event = { 861 | "path": "/test/remotepixel", 862 | "httpMethod": "GET", 863 | "headers": {}, 864 | "queryStringParameters": {"access_token": "yo"}, 865 | } 866 | resp = { 867 | "body": "heyyyy", 868 | "headers": { 869 | "Access-Control-Allow-Credentials": "true", 870 | "Access-Control-Allow-Methods": "GET", 871 | "Access-Control-Allow-Origin": "*", 872 | "Content-Type": "text/plain", 873 | }, 874 | "statusCode": 200, 875 | } 876 | res = app(event, {}) 877 | assert res == resp 878 | funct.assert_called_with(user="remotepixel") 879 | 880 | event = { 881 | "path": "/test/remotepixel", 882 | "httpMethod": "GET", 883 | "headers": {}, 884 | "queryStringParameters": {"inp": 1, "access_token": "yo"}, 885 | } 886 | resp = { 887 | "body": "heyyyy", 888 | "headers": { 889 | "Access-Control-Allow-Credentials": "true", 890 | "Access-Control-Allow-Methods": "GET", 891 | "Access-Control-Allow-Origin": "*", 892 | "Content-Type": "text/plain", 893 | }, 894 | "statusCode": 200, 895 | } 896 | res = app(event, {}) 897 | assert res == resp 898 | funct.assert_called_with(user="remotepixel", inp=1) 899 | 900 | event = { 901 | "path": "/test/remotepixel", 902 | "httpMethod": "GET", 903 | "headers": {}, 904 | "queryStringParameters": {"access_token": "yep"}, 905 | } 906 | resp = { 907 | "body": '{"message": "Invalid access token"}', 908 | "headers": {"Content-Type": "application/json"}, 909 | "statusCode": 500, 910 | } 911 | res = app(event, {}) 912 | assert res == resp 913 | 914 | event = { 915 | "path": "/test/remotepixel", 916 | "httpMethod": "GET", 917 | "headers": {}, 918 | "queryStringParameters": {"token": "yo"}, 919 | } 920 | resp = { 921 | "body": '{"message": "Invalid access token"}', 922 | "headers": {"Content-Type": "application/json"}, 923 | "statusCode": 500, 924 | } 925 | res = app(event, {}) 926 | assert res == resp 927 | 928 | monkeypatch.delenv("TOKEN", raising=False) 929 | 930 | event = { 931 | "path": "/test/remotepixel", 932 | "httpMethod": "GET", 933 | "headers": {}, 934 | "queryStringParameters": {"access_token": "yo"}, 935 | } 936 | resp = { 937 | "body": '{"message": "Invalid access token"}', 938 | "headers": {"Content-Type": "application/json"}, 939 | "statusCode": 500, 940 | } 941 | res = app(event, {}) 942 | assert res == resp 943 | 944 | # Clear logger handlers 945 | for h in app.log.handlers: 946 | app.log.removeHandler(h) 947 | 948 | 949 | def test_API_functionError(): 950 | """Add and parse route.""" 951 | app = proxy.API(name="test") 952 | funct = Mock(__name__="Mock", side_effect=Exception("hey something went wrong")) 953 | app._add_route("/test/", funct, methods=["GET"], cors=True) 954 | 955 | event = { 956 | "path": "/test/remotepixel", 957 | "httpMethod": "GET", 958 | "headers": {}, 959 | "queryStringParameters": {}, 960 | } 961 | resp = { 962 | "body": '{"errorMessage": "hey something went wrong"}', 963 | "headers": { 964 | "Access-Control-Allow-Credentials": "true", 965 | "Access-Control-Allow-Methods": "GET", 966 | "Access-Control-Allow-Origin": "*", 967 | "Content-Type": "application/json", 968 | }, 969 | "statusCode": 500, 970 | } 971 | res = app(event, {}) 972 | assert res == resp 973 | 974 | # Clear logger handlers 975 | for h in app.log.handlers: 976 | app.log.removeHandler(h) 977 | 978 | 979 | def test_API_Post(): 980 | """Should work as expected on POST request.""" 981 | app = proxy.API(name="test") 982 | funct = Mock(__name__="Mock", return_value=("OK", "text/plain", "heyyyy")) 983 | app._add_route("/test/", funct, methods=["GET", "POST"], cors=True) 984 | 985 | event = { 986 | "path": "/test/remotepixel", 987 | "httpMethod": "POST", 988 | "headers": {}, 989 | "queryStringParameters": {}, 990 | "body": b"0001", 991 | } 992 | resp = { 993 | "body": "heyyyy", 994 | "headers": { 995 | "Access-Control-Allow-Credentials": "true", 996 | "Access-Control-Allow-Methods": "GET,POST", 997 | "Access-Control-Allow-Origin": "*", 998 | "Content-Type": "text/plain", 999 | }, 1000 | "statusCode": 200, 1001 | } 1002 | res = app(event, {}) 1003 | assert res == resp 1004 | funct.assert_called_with(user="remotepixel", body=b"0001") 1005 | 1006 | event = { 1007 | "path": "/test/remotepixel", 1008 | "httpMethod": "POST", 1009 | "headers": {}, 1010 | "queryStringParameters": {}, 1011 | "body": "eyJ5byI6ICJ5byJ9", 1012 | "isBase64Encoded": True, 1013 | } 1014 | resp = { 1015 | "body": "heyyyy", 1016 | "headers": { 1017 | "Access-Control-Allow-Credentials": "true", 1018 | "Access-Control-Allow-Methods": "GET,POST", 1019 | "Access-Control-Allow-Origin": "*", 1020 | "Content-Type": "text/plain", 1021 | }, 1022 | "statusCode": 200, 1023 | } 1024 | res = app(event, {}) 1025 | assert res == resp 1026 | funct.assert_called_with(user="remotepixel", body='{"yo": "yo"}') 1027 | 1028 | event = { 1029 | "path": "/test/remotepixel", 1030 | "httpMethod": "GET", 1031 | "headers": {}, 1032 | "queryStringParameters": {}, 1033 | } 1034 | resp = { 1035 | "body": "heyyyy", 1036 | "headers": { 1037 | "Access-Control-Allow-Credentials": "true", 1038 | "Access-Control-Allow-Methods": "GET,POST", 1039 | "Access-Control-Allow-Origin": "*", 1040 | "Content-Type": "text/plain", 1041 | }, 1042 | "statusCode": 200, 1043 | } 1044 | res = app(event, {}) 1045 | assert res == resp 1046 | funct.assert_called_with(user="remotepixel") 1047 | 1048 | # Clear logger handlers 1049 | for h in app.log.handlers: 1050 | app.log.removeHandler(h) 1051 | 1052 | 1053 | def test_API_ctx(): 1054 | """Should work as expected and pass ctx and evt to the function.""" 1055 | app = proxy.API(name="test") 1056 | 1057 | @app.route("/", methods=["GET"], cors=True) 1058 | @app.pass_event 1059 | @app.pass_context 1060 | def print_id(ctx, evt, id, params=None): 1061 | return ( 1062 | "OK", 1063 | "application/json", 1064 | {"ctx": ctx, "evt": evt, "id": id, "params": params}, 1065 | ) 1066 | 1067 | event = { 1068 | "path": "/remotepixel", 1069 | "httpMethod": "GET", 1070 | "headers": {}, 1071 | "queryStringParameters": {"params": "1"}, 1072 | } 1073 | headers = { 1074 | "Access-Control-Allow-Credentials": "true", 1075 | "Access-Control-Allow-Methods": "GET", 1076 | "Access-Control-Allow-Origin": "*", 1077 | "Content-Type": "application/json", 1078 | } 1079 | 1080 | res = app(event, {"ctx": "jqtrde"}) 1081 | body = res["body"] 1082 | assert res["headers"] == headers 1083 | assert res["statusCode"] == 200 1084 | assert body["id"] == "remotepixel" 1085 | assert body["params"] == "1" 1086 | assert body["evt"] == event 1087 | assert body["ctx"] == {"ctx": "jqtrde"} 1088 | 1089 | # Clear logger handlers 1090 | for h in app.log.handlers: 1091 | app.log.removeHandler(h) 1092 | 1093 | 1094 | def test_API_multipleRoute(): 1095 | """Should work as expected.""" 1096 | app = proxy.API(name="test") 1097 | 1098 | @app.route("/", methods=["GET"], cors=True) 1099 | @app.route("/@", methods=["GET"], cors=True) 1100 | def print_id(user, num=None, params=None): 1101 | return ( 1102 | "OK", 1103 | "application/json", 1104 | json.dumps({"user": user, "num": num, "params": params}), 1105 | ) 1106 | 1107 | event = { 1108 | "path": "/remotepixel", 1109 | "httpMethod": "GET", 1110 | "headers": {}, 1111 | "queryStringParameters": {}, 1112 | } 1113 | headers = { 1114 | "Access-Control-Allow-Credentials": "true", 1115 | "Access-Control-Allow-Methods": "GET", 1116 | "Access-Control-Allow-Origin": "*", 1117 | "Content-Type": "application/json", 1118 | } 1119 | 1120 | res = app(event, {}) 1121 | body = json.loads(res["body"]) 1122 | assert res["statusCode"] == 200 1123 | assert res["headers"] == headers 1124 | assert body["user"] == "remotepixel" 1125 | assert not body.get("num") 1126 | assert not body.get("params") 1127 | 1128 | event = { 1129 | "path": "/remotepixel@1", 1130 | "httpMethod": "GET", 1131 | "headers": {}, 1132 | "queryStringParameters": {"params": "1"}, 1133 | } 1134 | 1135 | res = app(event, {}) 1136 | body = json.loads(res["body"]) 1137 | assert res["statusCode"] == 200 1138 | assert res["headers"] == headers 1139 | assert body["user"] == "remotepixel" 1140 | assert body["num"] == 1 1141 | assert body["params"] == "1" 1142 | 1143 | # Clear logger handlers 1144 | for h in app.log.handlers: 1145 | app.log.removeHandler(h) 1146 | 1147 | 1148 | def test_API_doc(): 1149 | """Should work as expected.""" 1150 | app = proxy.API(name="test") 1151 | 1152 | @app.route("/test", methods=["POST"]) 1153 | def _post(body: str) -> Tuple[str, str, str]: 1154 | """Return something.""" 1155 | return ("OK", "text/plain", "Yo") 1156 | 1157 | @app.route("/", methods=["GET"], tag=["users"], description="a route") 1158 | def _user(user: str) -> Tuple[str, str, str]: 1159 | """Return something.""" 1160 | return ("OK", "text/plain", "Yo") 1161 | 1162 | @app.route("/", methods=["GET"], token=True) 1163 | def _num(num: int) -> Tuple[str, str, str]: 1164 | """Return something.""" 1165 | return ("OK", "text/plain", "yo") 1166 | 1167 | @app.route("//", methods=["GET"]) 1168 | def _userandnum(user: str, num: int) -> Tuple[str, str, str]: 1169 | """Return something.""" 1170 | return ("OK", "text/plain", "yo") 1171 | 1172 | @app.route("//", methods=["GET"]) 1173 | def _options( 1174 | user: str, 1175 | num: float = 1.0, 1176 | opt1: str = "yep", 1177 | opt2: int = 2, 1178 | opt3: float = 2.0, 1179 | **kwargs, 1180 | ) -> Tuple[str, str, str]: 1181 | """Return something.""" 1182 | return ("OK", "text/plain", "yo") 1183 | 1184 | @app.route("//", methods=["GET"]) 1185 | @app.pass_context 1186 | @app.pass_event 1187 | def _ctx(evt: Dict, ctx: Dict, user: str, num: int) -> Tuple[str, str, str]: 1188 | """Return something.""" 1189 | return ("OK", "text/plain", "yo") 1190 | 1191 | event = { 1192 | "path": "/openapi.json", 1193 | "httpMethod": "GET", 1194 | "headers": {}, 1195 | "queryStringParameters": {}, 1196 | } 1197 | headers = { 1198 | "Access-Control-Allow-Credentials": "true", 1199 | "Access-Control-Allow-Methods": "GET", 1200 | "Access-Control-Allow-Origin": "*", 1201 | "Content-Type": "application/json", 1202 | } 1203 | 1204 | res = app(event, {}) 1205 | body = json.loads(res["body"]) 1206 | assert res["statusCode"] == 200 1207 | assert res["headers"] == headers 1208 | assert openapi_content == body 1209 | 1210 | event = { 1211 | "path": "/docs", 1212 | "httpMethod": "GET", 1213 | "headers": {}, 1214 | "queryStringParameters": {}, 1215 | } 1216 | headers = { 1217 | "Access-Control-Allow-Credentials": "true", 1218 | "Access-Control-Allow-Methods": "GET", 1219 | "Access-Control-Allow-Origin": "*", 1220 | "Content-Type": "text/html", 1221 | } 1222 | 1223 | res = app(event, {}) 1224 | assert res["statusCode"] == 200 1225 | assert res["headers"] == headers 1226 | 1227 | event = { 1228 | "path": "/redoc", 1229 | "httpMethod": "GET", 1230 | "headers": {}, 1231 | "queryStringParameters": {}, 1232 | } 1233 | headers = { 1234 | "Access-Control-Allow-Credentials": "true", 1235 | "Access-Control-Allow-Methods": "GET", 1236 | "Access-Control-Allow-Origin": "*", 1237 | "Content-Type": "text/html", 1238 | } 1239 | 1240 | res = app(event, {}) 1241 | assert res["statusCode"] == 200 1242 | assert res["headers"] == headers 1243 | 1244 | # Clear logger handlers 1245 | for h in app.log.handlers: 1246 | app.log.removeHandler(h) 1247 | 1248 | 1249 | def test_API_doc_apigw(): 1250 | """Should work as expected if request from api-gateway.""" 1251 | app = proxy.API(name="test") 1252 | 1253 | @app.route("/test", methods=["POST"]) 1254 | def _post(body: str) -> Tuple[str, str, str]: 1255 | """Return something.""" 1256 | return ("OK", "text/plain", "Yo") 1257 | 1258 | @app.route("/", methods=["GET"], tag=["users"], description="a route") 1259 | def _user(user: str) -> Tuple[str, str, str]: 1260 | """Return something.""" 1261 | return ("OK", "text/plain", "Yo") 1262 | 1263 | @app.route("/", methods=["GET"], token=True) 1264 | def _num(num: int) -> Tuple[str, str, str]: 1265 | """Return something.""" 1266 | return ("OK", "text/plain", "yo") 1267 | 1268 | @app.route("//", methods=["GET"]) 1269 | def _userandnum(user: str, num: int) -> Tuple[str, str, str]: 1270 | """Return something.""" 1271 | return ("OK", "text/plain", "yo") 1272 | 1273 | @app.route("//", methods=["GET"]) 1274 | def _options( 1275 | user: str, 1276 | num: float = 1.0, 1277 | opt1: str = "yep", 1278 | opt2: int = 2, 1279 | opt3: float = 2.0, 1280 | **kwargs, 1281 | ) -> Tuple[str, str, str]: 1282 | """Return something.""" 1283 | return ("OK", "text/plain", "yo") 1284 | 1285 | @app.route("//", methods=["GET"]) 1286 | @app.pass_context 1287 | @app.pass_event 1288 | def _ctx(evt: Dict, ctx: Dict, user: str, num: int) -> Tuple[str, str, str]: 1289 | """Return something.""" 1290 | return ("OK", "text/plain", "yo") 1291 | 1292 | event = { 1293 | "path": "/openapi.json", 1294 | "httpMethod": "GET", 1295 | "headers": {"Host": "afakeapi.execute-api.us-east-1.amazonaws.com"}, 1296 | "requestContext": {"stage": "production"}, 1297 | "queryStringParameters": {}, 1298 | } 1299 | headers = { 1300 | "Access-Control-Allow-Credentials": "true", 1301 | "Access-Control-Allow-Methods": "GET", 1302 | "Access-Control-Allow-Origin": "*", 1303 | "Content-Type": "application/json", 1304 | } 1305 | 1306 | res = app(event, {}) 1307 | body = json.loads(res["body"]) 1308 | assert res["statusCode"] == 200 1309 | assert res["headers"] == headers 1310 | assert openapi_apigw_content == body 1311 | 1312 | event = { 1313 | "path": "/docs", 1314 | "httpMethod": "GET", 1315 | "headers": {"Host": "afakeapi.execute-api.us-east-1.amazonaws.com"}, 1316 | "requestContext": {"stage": "production"}, 1317 | "queryStringParameters": {}, 1318 | } 1319 | headers = { 1320 | "Access-Control-Allow-Credentials": "true", 1321 | "Access-Control-Allow-Methods": "GET", 1322 | "Access-Control-Allow-Origin": "*", 1323 | "Content-Type": "text/html", 1324 | } 1325 | 1326 | res = app(event, {}) 1327 | assert res["statusCode"] == 200 1328 | assert res["headers"] == headers 1329 | 1330 | # Clear logger handlers 1331 | for h in app.log.handlers: 1332 | app.log.removeHandler(h) 1333 | 1334 | 1335 | def test_API_docCustomDomain(): 1336 | """Should work as expected.""" 1337 | app = proxy.API(name="test") 1338 | 1339 | @app.route("/test", methods=["POST"]) 1340 | def _post(body: str) -> Tuple[str, str, str]: 1341 | """Return something.""" 1342 | return ("OK", "text/plain", "Yo") 1343 | 1344 | @app.route("/", methods=["GET"], tag=["users"], description="a route") 1345 | def _user(user: str) -> Tuple[str, str, str]: 1346 | """Return something.""" 1347 | return ("OK", "text/plain", "Yo") 1348 | 1349 | @app.route("/", methods=["GET"], token=True) 1350 | def _num(num: int) -> Tuple[str, str, str]: 1351 | """Return something.""" 1352 | return ("OK", "text/plain", "yo") 1353 | 1354 | @app.route("//", methods=["GET"]) 1355 | def _userandnum(user: str, num: int) -> Tuple[str, str, str]: 1356 | """Return something.""" 1357 | return ("OK", "text/plain", "yo") 1358 | 1359 | @app.route("//", methods=["GET"]) 1360 | def _options( 1361 | user: str, 1362 | num: float = 1.0, 1363 | opt1: str = "yep", 1364 | opt2: int = 2, 1365 | opt3: float = 2.0, 1366 | **kwargs, 1367 | ) -> Tuple[str, str, str]: 1368 | """Return something.""" 1369 | return ("OK", "text/plain", "yo") 1370 | 1371 | @app.route("//", methods=["GET"]) 1372 | @app.pass_context 1373 | @app.pass_event 1374 | def _ctx(evt: Dict, ctx: Dict, user: str, num: int) -> Tuple[str, str, str]: 1375 | """Return something.""" 1376 | return ("OK", "text/plain", "yo") 1377 | 1378 | event = { 1379 | "resource": "/{proxy+}", 1380 | "pathParameters": {"proxy": "openapi.json"}, 1381 | "path": "/api/openapi.json", 1382 | "httpMethod": "GET", 1383 | "headers": {}, 1384 | "queryStringParameters": {}, 1385 | } 1386 | headers = { 1387 | "Access-Control-Allow-Credentials": "true", 1388 | "Access-Control-Allow-Methods": "GET", 1389 | "Access-Control-Allow-Origin": "*", 1390 | "Content-Type": "application/json", 1391 | } 1392 | 1393 | res = app(event, {}) 1394 | body = json.loads(res["body"]) 1395 | assert res["statusCode"] == 200 1396 | assert res["headers"] == headers 1397 | assert openapi_custom_content == body 1398 | 1399 | # Clear logger handlers 1400 | for h in app.log.handlers: 1401 | app.log.removeHandler(h) 1402 | 1403 | 1404 | def test_routeRegex(): 1405 | """Add and parse route.""" 1406 | app = proxy.API(name="test") 1407 | funct_one = Mock(__name__="Mock", return_value=("OK", "text/plain", "heyyyy")) 1408 | funct_two = Mock(__name__="Mock", return_value=("OK", "text/plain", "yooooo")) 1409 | app._add_route( 1410 | "/test//", 1411 | funct_two, 1412 | methods=["GET"], 1413 | cors=True, 1414 | ) 1415 | app._add_route( 1416 | "/test//", funct_one, methods=["GET"], cors=True 1417 | ) 1418 | event = { 1419 | "path": "/test/1234/pixel", 1420 | "httpMethod": "GET", 1421 | "headers": {}, 1422 | "queryStringParameters": {}, 1423 | } 1424 | resp = { 1425 | "body": "heyyyy", 1426 | "headers": { 1427 | "Access-Control-Allow-Credentials": "true", 1428 | "Access-Control-Allow-Methods": "GET", 1429 | "Access-Control-Allow-Origin": "*", 1430 | "Content-Type": "text/plain", 1431 | }, 1432 | "statusCode": 200, 1433 | } 1434 | res = app(event, {}) 1435 | assert res == resp 1436 | funct_one.assert_called_with(number="1234", name="pixel") 1437 | 1438 | event = { 1439 | "path": "/test/1234/pix", 1440 | "httpMethod": "GET", 1441 | "headers": {}, 1442 | "queryStringParameters": {}, 1443 | } 1444 | resp = { 1445 | "body": "yooooo", 1446 | "headers": { 1447 | "Access-Control-Allow-Credentials": "true", 1448 | "Access-Control-Allow-Methods": "GET", 1449 | "Access-Control-Allow-Origin": "*", 1450 | "Content-Type": "text/plain", 1451 | }, 1452 | "statusCode": 200, 1453 | } 1454 | res = app(event, {}) 1455 | assert res == resp 1456 | funct_two.assert_called_with(number="1234", name="pix") 1457 | 1458 | # Clear logger handlers 1459 | for h in app.log.handlers: 1460 | app.log.removeHandler(h) 1461 | 1462 | 1463 | def test_routeRegexFailing(): 1464 | """Add and parse route.""" 1465 | app = proxy.API(name="test") 1466 | funct = Mock(__name__="Mock", return_value=("OK", "text/plain", "yooooo")) 1467 | app._add_route( 1468 | r"/test//", funct, methods=["GET"], cors=True 1469 | ) 1470 | event = { 1471 | "path": "/test/user1234/rugby", 1472 | "httpMethod": "GET", 1473 | "headers": {}, 1474 | "queryStringParameters": {}, 1475 | } 1476 | with pytest.raises(Exception): 1477 | app(event, {}) 1478 | funct.assert_not_called() 1479 | 1480 | # Clear logger handlers 1481 | for h in app.log.handlers: 1482 | app.log.removeHandler(h) 1483 | 1484 | 1485 | def testApigwPath(): 1486 | """test api call parsing.""" 1487 | # resource "/", no apigwg, noproxy, no path mapping 1488 | event = {"path": "/test/1234/pix", "headers": {}} 1489 | p = proxy.ApigwPath(event) 1490 | assert p.path == "/test/1234/pix" 1491 | assert not p.apigw_stage 1492 | assert not p.api_prefix 1493 | assert not p.path_mapping 1494 | assert p.prefix == "" 1495 | 1496 | event = {"resource": "/", "path": "/test/1234/pix", "headers": {}} 1497 | p = proxy.ApigwPath(event) 1498 | assert p.path == "/test/1234/pix" 1499 | assert not p.apigw_stage 1500 | assert not p.api_prefix 1501 | assert not p.path_mapping 1502 | assert p.prefix == "" 1503 | 1504 | # resource "proxy+", no apigwg, no path mapping, no api prefix 1505 | event = { 1506 | "resource": "/{proxy+}", 1507 | "pathParameters": {"proxy": "test/1234/pix"}, 1508 | "path": "/test/1234/pix", 1509 | "headers": {}, 1510 | } 1511 | p = proxy.ApigwPath(event) 1512 | assert p.path == "/test/1234/pix" 1513 | assert not p.apigw_stage 1514 | assert not p.api_prefix 1515 | assert not p.path_mapping 1516 | assert p.prefix == "" 1517 | 1518 | # resource "proxy+", no apigwg, no path mapping, api prefix (api) 1519 | event = { 1520 | "resource": "/api/{proxy+}", 1521 | "pathParameters": {"proxy": "test/1234/pix"}, 1522 | "path": "/api/test/1234/pix", 1523 | "headers": {}, 1524 | } 1525 | p = proxy.ApigwPath(event) 1526 | assert p.path == "/test/1234/pix" 1527 | assert not p.apigw_stage 1528 | assert p.api_prefix == "/api" 1529 | assert not p.path_mapping 1530 | assert p.prefix == "/api" 1531 | 1532 | # resource "proxy+", no apigwg, path mapping (prefix), api prefix (api) 1533 | event = { 1534 | "resource": "/api/{proxy+}", 1535 | "pathParameters": {"proxy": "test/1234/pix"}, 1536 | "path": "/prefix/api/test/1234/pix", 1537 | "headers": {}, 1538 | } 1539 | p = proxy.ApigwPath(event) 1540 | assert p.path == "/test/1234/pix" 1541 | assert not p.apigw_stage 1542 | assert p.api_prefix == "/api" 1543 | assert p.path_mapping == "/prefix" 1544 | assert p.prefix == "/prefix/api" 1545 | 1546 | # resource "proxy+", apigwg (production), api prefix (api) 1547 | event = { 1548 | "resource": "/api/{proxy+}", 1549 | "pathParameters": {"proxy": "test/1234/pix"}, 1550 | "path": "/prefix/api/test/1234/pix", 1551 | "headers": {"host": "afakeapi.execute-api.us-east-1.amazonaws.com"}, 1552 | "requestContext": {"stage": "production"}, 1553 | } 1554 | p = proxy.ApigwPath(event) 1555 | assert p.path == "/test/1234/pix" 1556 | assert p.apigw_stage == "production" 1557 | assert p.api_prefix == "/api" 1558 | assert not p.path_mapping 1559 | assert p.prefix == "/production/api" 1560 | 1561 | # New HTTP API integration 1562 | # by `default` api gateway will deploy the API with a `$default` stage 1563 | # pointing to the `root` host. 1564 | # $default -> https://ggnbmhlvlf.execute-api.us-east-1.amazonaws.com 1565 | # You can then add other stage: 1566 | # $default -> https://ggnbmhlvlf.execute-api.us-east-1.amazonaws.com 1567 | # test -> https://ggnbmhlvlf.execute-api.us-east-1.amazonaws.com/test 1568 | # 1569 | # resource "proxy+", apigwg stage ($default), no path mapping, no api prefix 1570 | event = { 1571 | "version": "1.0", 1572 | "resource": "/{proxy+}", 1573 | "pathParameters": {"proxy": "test/1234/pix"}, 1574 | "path": "test/1234/pix", 1575 | "headers": {"host": "afakeapi.execute-api.us-east-1.amazonaws.com"}, 1576 | "requestContext": {"stage": "$default"}, 1577 | } 1578 | p = proxy.ApigwPath(event) 1579 | assert p.path == "/test/1234/pix" 1580 | assert p.apigw_stage == "$default" 1581 | assert not p.api_prefix 1582 | assert not p.path_mapping 1583 | assert not p.prefix 1584 | 1585 | # resource "proxy+", apigwg stage (production), no path mapping, no api prefix 1586 | event = { 1587 | "version": "1.0", 1588 | "resource": "/{proxy+}", 1589 | "pathParameters": {"proxy": "test/1234/pix"}, 1590 | "path": "test/1234/pix", 1591 | "headers": {"host": "afakeapi.execute-api.us-east-1.amazonaws.com"}, 1592 | "requestContext": {"stage": "production"}, 1593 | } 1594 | p = proxy.ApigwPath(event) 1595 | assert p.path == "/test/1234/pix" 1596 | assert p.apigw_stage == "production" 1597 | assert not p.api_prefix 1598 | assert not p.path_mapping 1599 | assert p.prefix == "/production" 1600 | 1601 | 1602 | def testApigwHostUrl(): 1603 | """Test url property.""" 1604 | app = proxy.API(name="test") 1605 | funct = Mock(__name__="Mock", return_value=("OK", "text/plain", "heyyyy")) 1606 | app._add_route("/test//", funct, methods=["GET"], cors=True) 1607 | 1608 | # resource "/", no apigwg, noproxy, no path mapping 1609 | event = { 1610 | "path": "/test/1234/pix", 1611 | "headers": {"Host": "test.apigw.com"}, 1612 | "httpMethod": "GET", 1613 | } 1614 | _ = app(event, {}) 1615 | assert app.host == "https://test.apigw.com" 1616 | 1617 | event = { 1618 | "resource": "/", 1619 | "path": "/test/1234/pix", 1620 | "headers": {"Host": "test.apigw.com"}, 1621 | "httpMethod": "GET", 1622 | } 1623 | _ = app(event, {}) 1624 | assert app.host == "https://test.apigw.com" 1625 | 1626 | # resource "proxy+", apigwg (production), api prefix (api) 1627 | event = { 1628 | "resource": "/api/{proxy+}", 1629 | "pathParameters": {"proxy": "test/1234/pix"}, 1630 | "path": "/api/test/1234/pix", 1631 | "headers": { 1632 | "X-Forwarded-Host": "abcdefghij.execute-api.eu-central-1.amazonaws.com" 1633 | }, 1634 | "requestContext": {"stage": "production"}, 1635 | "httpMethod": "GET", 1636 | } 1637 | _ = app(event, {}) 1638 | assert ( 1639 | app.host 1640 | == "https://abcdefghij.execute-api.eu-central-1.amazonaws.com/production" 1641 | ) 1642 | 1643 | # resource "proxy+", apigwg HTTP ($default) 1644 | event = { 1645 | "version": "1.0", 1646 | "resource": "/{proxy+}", 1647 | "pathParameters": {"proxy": "test/1234/pix"}, 1648 | "path": "test/1234/pix", 1649 | "headers": { 1650 | "X-Forwarded-Host": "abcdefghij.execute-api.eu-central-1.amazonaws.com" 1651 | }, 1652 | "requestContext": {"stage": "$default"}, 1653 | "httpMethod": "GET", 1654 | } 1655 | _ = app(event, {}) 1656 | assert app.host == "https://abcdefghij.execute-api.eu-central-1.amazonaws.com" 1657 | 1658 | # resource "proxy+", no apigwg, no path mapping, no api prefix 1659 | event = { 1660 | "resource": "/{proxy+}", 1661 | "pathParameters": {"proxy": "test/1234/pix"}, 1662 | "path": "/test/1234/pix", 1663 | "headers": {"Host": "test.apigw.com"}, 1664 | "httpMethod": "GET", 1665 | } 1666 | _ = app(event, {}) 1667 | assert app.host == "https://test.apigw.com" 1668 | 1669 | # resource "proxy+", no apigwg, path mapping (prefix), api prefix (api) 1670 | event = { 1671 | "resource": "/api/{proxy+}", 1672 | "pathParameters": {"proxy": "test/1234/pix"}, 1673 | "path": "/prefix/api/test/1234/pix", 1674 | "headers": {"Host": "test.apigw.com"}, 1675 | "httpMethod": "GET", 1676 | } 1677 | 1678 | _ = app(event, {}) 1679 | assert app.host == "https://test.apigw.com/prefix" 1680 | 1681 | # Local 1682 | app.https = False 1683 | event = { 1684 | "resource": "/", 1685 | "path": "/api/test/1234/pix", 1686 | "headers": {"Host": "127.0.0.0:8000"}, 1687 | "httpMethod": "GET", 1688 | } 1689 | 1690 | _ = app(event, {}) 1691 | assert app.host == "http://127.0.0.0:8000" 1692 | 1693 | 1694 | def test_API_simpleRoute(): 1695 | """Should work as expected.""" 1696 | app = proxy.API(name="test") 1697 | 1698 | @app.post("/test") 1699 | def _post(body: str) -> Tuple[str, str, str]: 1700 | """Return something.""" 1701 | return ("OK", "text/plain", body) 1702 | 1703 | @app.get("/", tag=["users"], description="a route", cors=True) 1704 | def _user(user: str) -> Tuple[str, str, str]: 1705 | """Return something.""" 1706 | return ("OK", "text/plain", user) 1707 | 1708 | event = { 1709 | "path": "/remotepixel", 1710 | "httpMethod": "GET", 1711 | "headers": {}, 1712 | "queryStringParameters": {}, 1713 | } 1714 | headers = { 1715 | "Access-Control-Allow-Credentials": "true", 1716 | "Access-Control-Allow-Methods": "GET", 1717 | "Access-Control-Allow-Origin": "*", 1718 | "Content-Type": "text/plain", 1719 | } 1720 | 1721 | res = app(event, {}) 1722 | assert res["statusCode"] == 200 1723 | assert res["headers"] == headers 1724 | assert res["body"] == "remotepixel" 1725 | 1726 | event = { 1727 | "path": "/test", 1728 | "httpMethod": "POST", 1729 | "headers": {}, 1730 | "queryStringParameters": {}, 1731 | "body": "yo", 1732 | } 1733 | headers = { 1734 | "Content-Type": "text/plain", 1735 | } 1736 | res = app(event, {}) 1737 | assert res["statusCode"] == 200 1738 | assert res["headers"] == headers 1739 | assert res["body"] == "yo" 1740 | 1741 | # Clear logger handlers 1742 | for h in app.log.handlers: 1743 | app.log.removeHandler(h) 1744 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | 2 | [tox] 3 | envlist = py36,py37 4 | 5 | [flake8] 6 | ignore = D203 7 | exclude = .git,__pycache__,docs/source/conf.py,old,build,dist 8 | max-complexity = 10 9 | max-line-length = 90 10 | 11 | [mypy] 12 | no_strict_optional = true 13 | ignore_missing_imports = True 14 | 15 | [testenv] 16 | extras = test 17 | commands= 18 | python -m pytest --cov lambda_proxy --cov-report term-missing --ignore=venv 19 | 20 | # Autoformatter 21 | [testenv:black] 22 | basepython = python3 23 | skip_install = true 24 | deps = 25 | black 26 | commands = 27 | black 28 | 29 | # Release tooling 30 | [testenv:build] 31 | basepython = python3 32 | skip_install = true 33 | deps = 34 | wheel 35 | setuptools 36 | commands = 37 | python setup.py sdist 38 | 39 | [testenv:release] 40 | basepython = python3 41 | skip_install = true 42 | deps = 43 | {[testenv:build]deps} 44 | twine >= 1.5.0 45 | commands = 46 | {[testenv:build]commands} 47 | twine upload --skip-existing dist/* 48 | --------------------------------------------------------------------------------