├── tests ├── unit │ ├── __init__.py │ ├── deploy │ │ ├── __init__.py │ │ ├── test_packager.py │ │ └── test_swagger.py │ ├── conftest.py │ ├── test_pipeline.py │ ├── test_policy.py │ ├── test_config.py │ ├── test_package.py │ └── test_local.py ├── functional │ ├── __init__.py │ ├── cli │ │ ├── __init__.py │ │ └── test_factory.py │ ├── conftest.py │ ├── test_package.py │ ├── test_utils.py │ └── test_deployer.py ├── integration │ ├── testapp │ │ ├── requirements.txt │ │ ├── chalicelib │ │ │ └── __init__.py │ │ └── app.py │ └── test_features.py └── conftest.py ├── chalice ├── deploy │ ├── __init__.py │ ├── swagger.py │ └── packager.py ├── __init__.py ├── compat.py ├── prompts.py ├── utils.py ├── app.pyi ├── logs.py ├── policy.py ├── cli │ └── factory.py ├── constants.py ├── package.py ├── local.py └── config.py ├── .coveragerc ├── setup.cfg ├── docs ├── source │ ├── chalicedocs.py │ ├── quickstart.rst │ ├── index.rst │ ├── topics │ │ ├── multifile.rst │ │ ├── stages.rst │ │ ├── logging.rst │ │ ├── routing.rst │ │ ├── packaging.rst │ │ ├── views.rst │ │ ├── sdks.rst │ │ └── configfile.rst │ ├── api.rst │ ├── conf.py │ └── upgrading.rst └── Makefile ├── NOTICE ├── requirements-docs.txt ├── requirements-test.txt ├── .gitignore ├── requirements-dev.txt ├── MANIFEST.in ├── .travis.yml ├── setup.py ├── CONTRIBUTING.rst ├── Makefile └── CHANGELOG.rst /tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /chalice/deploy/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/functional/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/functional/cli/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/deploy/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/deploy/test_packager.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /tests/integration/testapp/requirements.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/source/chalicedocs.py: -------------------------------------------------------------------------------- 1 | def setup(app): 2 | pass 3 | -------------------------------------------------------------------------------- /tests/integration/testapp/chalicelib/__init__.py: -------------------------------------------------------------------------------- 1 | MESSAGE = "success" 2 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | chalice 2 | Copyright 2015 James Saryerwinnie. All Rights Reserved. 3 | -------------------------------------------------------------------------------- /requirements-docs.txt: -------------------------------------------------------------------------------- 1 | Sphinx==1.4.1 2 | guzzle_sphinx_theme>=0.7.10,<0.8 3 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | pytest==3.0.3 2 | py==1.4.31 3 | pygments==2.1.3 4 | mock==2.0.0 5 | requests==2.11.1 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | sample/ 2 | .cache 3 | venv 4 | docs/build/ 5 | .idea 6 | *.py? 7 | __pycache__/ 8 | .coverage 9 | chalice.egg-info/ 10 | -------------------------------------------------------------------------------- /tests/functional/conftest.py: -------------------------------------------------------------------------------- 1 | from pytest import fixture 2 | 3 | 4 | @fixture(autouse=True) 5 | def ensure_no_local_config(no_local_config): 6 | pass 7 | -------------------------------------------------------------------------------- /docs/source/quickstart.rst: -------------------------------------------------------------------------------- 1 | Quickstart and Tutorial 2 | ======================= 3 | 4 | .. include:: ../../README.rst 5 | :start-after: quick-start-begin 6 | :end-before: quick-start-end 7 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | coverage==4.0.3 2 | flake8==2.5.0 3 | tox==2.2.1 4 | wheel==0.26.0 5 | doc8==0.7.0 6 | pylint==1.5.5 7 | pytest-cov==2.3.1 8 | pydocstyle==1.0.0 9 | -r requirements-test.txt 10 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CONTRIBUTING.rst 2 | include CHANGELOG.rst 3 | include LICENSE 4 | include NOTICE 5 | include README.rst 6 | recursive-include chalice *.json 7 | 8 | recursive-exclude * __pycache__ 9 | recursive-exclude * *.py[co] 10 | -------------------------------------------------------------------------------- /chalice/__init__.py: -------------------------------------------------------------------------------- 1 | from chalice.app import Chalice 2 | from chalice.app import ( 3 | ChaliceViewError, BadRequestError, UnauthorizedError, ForbiddenError, 4 | NotFoundError, ConflictError, TooManyRequestsError, Response 5 | ) 6 | 7 | __version__ = '0.7.0' 8 | -------------------------------------------------------------------------------- /tests/unit/conftest.py: -------------------------------------------------------------------------------- 1 | from pytest import fixture 2 | 3 | from chalice.app import Chalice 4 | 5 | 6 | @fixture(autouse=True) 7 | def ensure_no_local_config(no_local_config): 8 | pass 9 | 10 | 11 | @fixture 12 | def sample_app(): 13 | app = Chalice('sample') 14 | 15 | @app.route('/') 16 | def foo(): 17 | return {} 18 | 19 | return app 20 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: false 3 | matrix: 4 | include: 5 | - python: "3.5" 6 | env: TEST_TYPE=typecheck 7 | - python: "2.7" 8 | env: TEST_TYPE=coverage 9 | - python: "2.7" 10 | env: TEST_TYPE=check 11 | install: 12 | - if [[ $TRAVIS_PYTHON_VERSION != 3.* ]]; then pip install -e . ; fi 13 | - if [[ $TRAVIS_PYTHON_VERSION != 3.* ]]; then travis_retry pip install -r requirements-dev.txt; fi 14 | - if [[ $TRAVIS_PYTHON_VERSION == '3.5' ]]; then pip install mypy==0.501; fi 15 | script: 16 | - make $TEST_TYPE 17 | after_success: 18 | - if [[ $TEST_TYPE == 'coverage' ]]; then pip install codecov==2.0.5 && codecov; fi 19 | -------------------------------------------------------------------------------- /chalice/compat.py: -------------------------------------------------------------------------------- 1 | import os 2 | import platform 3 | 4 | 5 | if platform.system() == 'Windows': 6 | def pip_script_in_venv(venv_dir): 7 | # type: (str) -> str 8 | pip_exe = os.path.join(venv_dir, 'Scripts', 'pip.exe') 9 | return pip_exe 10 | 11 | def site_packages_dir_in_venv(venv_dir): 12 | # type: (str) -> str 13 | deps_dir = os.path.join(venv_dir, 'Lib', 'site-packages') 14 | return deps_dir 15 | 16 | else: 17 | # Posix platforms. 18 | 19 | def pip_script_in_venv(venv_dir): 20 | # type: (str) -> str 21 | pip_exe = os.path.join(venv_dir, 'bin', 'pip') 22 | return pip_exe 23 | 24 | def site_packages_dir_in_venv(venv_dir): 25 | # type: (str) -> str 26 | python_dir = os.listdir(os.path.join(venv_dir, 'lib'))[0] 27 | deps_dir = os.path.join(venv_dir, 'lib', python_dir, 'site-packages') 28 | return deps_dir 29 | -------------------------------------------------------------------------------- /chalice/prompts.py: -------------------------------------------------------------------------------- 1 | from typing import Any # noqa 2 | 3 | 4 | WELCOME_PROMPT = r""" 5 | 6 | ___ _ _ _ _ ___ ___ ___ 7 | / __|| || | /_\ | | |_ _|/ __|| __| 8 | | (__ | __ | / _ \ | |__ | || (__ | _| 9 | \___||_||_|/_/ \_\|____||___|\___||___| 10 | 11 | 12 | The python serverless microframework for AWS allows 13 | you to quickly create and deploy applications using 14 | Amazon API Gateway and AWS Lambda. 15 | 16 | Right now it's under developer preview and we're looking 17 | for customer feedback. 18 | Be aware that there are several features missing: 19 | 20 | * No support for authentication or authorization 21 | * No support for stages 22 | * No support for CORS 23 | 24 | If you'd like to proceded, then answer the questions 25 | below. 26 | 27 | Please enter the project name""" 28 | 29 | 30 | def getting_started_prompt(click): 31 | # type: (Any) -> bool 32 | return click.prompt(WELCOME_PROMPT) 33 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import setup, find_packages 3 | 4 | 5 | with open('README.rst') as readme_file: 6 | README = readme_file.read() 7 | 8 | 9 | install_requires = [ 10 | 'click==6.6', 11 | 'botocore>=1.5.0,<2.0.0', 12 | 'virtualenv>=15.0.0,<16.0.0', 13 | 'typing==3.5.3.0', 14 | ] 15 | 16 | setup( 17 | name='chalice', 18 | version='0.7.0', 19 | description="Microframework", 20 | long_description=README, 21 | author="James Saryerwinnie", 22 | author_email='js@jamesls.com', 23 | url='https://github.com/jamesls/chalice', 24 | python_requires=">=2.7,<3", 25 | packages=find_packages(exclude=['tests']), 26 | install_requires=install_requires, 27 | license="Apache License 2.0", 28 | package_data={'chalice': ['*.json']}, 29 | include_package_data=True, 30 | zip_safe=False, 31 | keywords='chalice', 32 | entry_points={ 33 | 'console_scripts': [ 34 | 'chalice = chalice.cli:main', 35 | ] 36 | }, 37 | classifiers=[ 38 | 'Development Status :: 2 - Pre-Alpha', 39 | 'Intended Audience :: Developers', 40 | 'License :: OSI Approved :: Apache Software License', 41 | 'Natural Language :: English', 42 | "Programming Language :: Python :: 2", 43 | 'Programming Language :: Python :: 2.7', 44 | ], 45 | ) 46 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributing 3 | ============ 4 | 5 | 6 | 7 | Development Environment Setup 8 | ============================= 9 | 10 | First, create a virtual environment for chalice:: 11 | 12 | $ virtualenv venv-chalice 13 | $ source venv-chalice/bin/activate 14 | 15 | Keep in mind that chalice is designed to work with AWS Lambda, 16 | so you should ensure your virtual environment is created with 17 | python 2.7, which is the version of python currently supported by 18 | AWS Lambda. 19 | 20 | Next, you'll need to install chalice. The easiest way to configure this 21 | is to use:: 22 | 23 | $ pip install -e . 24 | 25 | Run this command the root directory of the chalice repo. 26 | 27 | Next, you have a few options. There are various requirements files 28 | depending on what you'd like to do. 29 | 30 | For example, if you'd like to work on chalice, either fixing bugs or 31 | adding new features, install ``requirements-dev.txt``:: 32 | 33 | 34 | $ pip install -r requirements-dev.txt 35 | 36 | 37 | If you'd like to just build the docs, install ``requirements-docs.txt``:: 38 | 39 | $ pip install -r requirements-docs.txt 40 | 41 | And finally, if you only want to run the tests, you can run:: 42 | 43 | $ pip install -r requirements-test.txt 44 | 45 | Note that ``requirements-dev.txt`` automatically includes 46 | ``requirements-test.txt`` so if you're interested in chalice development you 47 | only need to install ``requirements-dev.txt``. 48 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Eventually I'll add: 2 | # py.test --cov chalice --cov-report term-missing --cov-fail-under 95 tests/ 3 | # which will fail if tests are under 95% 4 | TESTS=tests/unit tests/functional 5 | 6 | check: 7 | ###### FLAKE8 ##### 8 | # No unused imports, no undefined vars, 9 | flake8 --ignore=E731,W503 --exclude chalice/__init__.py --max-complexity 10 chalice/ 10 | # 11 | # 12 | # Basic error checking in test code 13 | pyflakes tests/unit/ tests/functional/ 14 | ##### DOC8 ###### 15 | # Correct rst formatting for documentation 16 | # 17 | ## 18 | doc8 docs/source --ignore-path docs/source/topics/multifile.rst 19 | # 20 | # 21 | # 22 | # Proper docstring conventions according to pep257 23 | # 24 | # 25 | pydocstyle --add-ignore=D100,D101,D102,D103,D104,D105,D204,D301 chalice/ 26 | # 27 | # 28 | # 29 | ###### PYLINT ERRORS ONLY ###### 30 | # 31 | # 32 | # 33 | pylint --rcfile .pylintrc -E chalice 34 | 35 | pylint: 36 | ###### PYLINT ###### 37 | # Python linter. This will generally not have clean output. 38 | # So you'll need to manually verify this output. 39 | # 40 | # 41 | pylint --rcfile .pylintrc chalice 42 | 43 | test: 44 | py.test -v $(TESTS) 45 | 46 | typecheck: 47 | mypy --py2 --ignore-missing-imports --follow-imports=skip -p chalice --disallow-untyped-defs --strict-optional --warn-no-return 48 | 49 | coverage: 50 | py.test --cov chalice --cov-report term-missing $(TESTS) 51 | 52 | coverage-unit: 53 | py.test --cov chalice --cov-report term-missing tests/unit 54 | 55 | htmlcov: 56 | py.test --cov chalice --cov-report html $(TESTS) 57 | rm -rf /tmp/htmlcov && mv htmlcov /tmp/ 58 | open /tmp/htmlcov/index.html 59 | 60 | prcheck: check typecheck test 61 | -------------------------------------------------------------------------------- /tests/functional/test_package.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from chalice.config import Config 4 | from chalice import Chalice 5 | from chalice import package 6 | 7 | 8 | 9 | def _create_app_structure(tmpdir): 10 | appdir = tmpdir.mkdir('app') 11 | appdir.join('app.py').write('# Test app') 12 | appdir.mkdir('.chalice') 13 | return appdir 14 | 15 | 16 | def sample_app(): 17 | app = Chalice("sample_app") 18 | 19 | @app.route('/') 20 | def index(): 21 | return {"hello": "world"} 22 | 23 | return app 24 | 25 | 26 | def test_can_create_app_packager_with_no_autogen(tmpdir): 27 | appdir = _create_app_structure(tmpdir) 28 | 29 | outdir = tmpdir.mkdir('outdir') 30 | config = Config.create(project_dir=str(appdir), 31 | chalice_app=sample_app()) 32 | p = package.create_app_packager(config) 33 | p.package_app(config, str(outdir)) 34 | # We're not concerned with the contents of the files 35 | # (those are tested in the unit tests), we just want to make 36 | # sure they're written to disk and look (mostly) right. 37 | contents = os.listdir(str(outdir)) 38 | assert 'deployment.zip' in contents 39 | assert 'sam.json' in contents 40 | 41 | 42 | def test_will_create_outdir_if_needed(tmpdir): 43 | appdir = _create_app_structure(tmpdir) 44 | outdir = str(appdir.join('outdir')) 45 | config = Config.create(project_dir=str(appdir), 46 | chalice_app=sample_app()) 47 | p = package.create_app_packager(config) 48 | p.package_app(config, str(outdir)) 49 | contents = os.listdir(str(outdir)) 50 | assert 'deployment.zip' in contents 51 | assert 'sam.json' in contents 52 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | ======================================== 2 | Python Serverless Microframework for AWS 3 | ======================================== 4 | 5 | The python serverless microframework for AWS allows you to quickly create and 6 | deploy applications that use Amazon API Gateway and AWS Lambda. 7 | It provides: 8 | 9 | * A command line tool for creating, deploying, and managing your app 10 | * A familiar and easy to use API for declaring views in python code 11 | * Automatic IAM policy generation 12 | 13 | 14 | :: 15 | 16 | $ pip install chalice 17 | $ chalice new-project helloworld && cd helloworld 18 | $ cat app.py 19 | 20 | from chalice import Chalice 21 | 22 | app = Chalice(app_name="helloworld") 23 | 24 | @app.route("/") 25 | def index(): 26 | return {"hello": "world"} 27 | 28 | $ chalice deploy 29 | ... 30 | Your application is available at: https://endpoint/dev 31 | 32 | $ curl https://endpoint/dev 33 | {"hello": "world"} 34 | 35 | Up and running in less than 30 seconds. 36 | 37 | **This project is published as a preview project and is not yet recommended for 38 | production APIs.** Give this project a try and share your feedback with us 39 | on `github `__. 40 | 41 | 42 | Getting Started 43 | --------------- 44 | 45 | .. toctree:: 46 | :maxdepth: 2 47 | 48 | quickstart 49 | 50 | 51 | Topics 52 | ------ 53 | 54 | .. toctree:: 55 | :maxdepth: 2 56 | 57 | topics/routing 58 | topics/views 59 | topics/configfile 60 | topics/multifile 61 | topics/logging 62 | topics/sdks 63 | topics/stages 64 | topics/packaging 65 | 66 | 67 | API Reference 68 | ------------- 69 | 70 | .. toctree:: 71 | :maxdepth: 2 72 | 73 | api 74 | 75 | 76 | Upgrade Notes 77 | ------------- 78 | 79 | .. toctree:: 80 | :maxdepth: 2 81 | 82 | upgrading 83 | 84 | 85 | Indices and tables 86 | ================== 87 | 88 | * :ref:`genindex` 89 | * :ref:`search` 90 | -------------------------------------------------------------------------------- /tests/functional/test_utils.py: -------------------------------------------------------------------------------- 1 | import zipfile 2 | import json 3 | 4 | from chalice import utils 5 | 6 | 7 | def test_can_zip_single_file(tmpdir): 8 | source = tmpdir.mkdir('sourcedir') 9 | source.join('hello.txt').write('hello world') 10 | outfile = str(tmpdir.join('out.zip')) 11 | utils.create_zip_file(source_dir=str(source), 12 | outfile=outfile) 13 | with zipfile.ZipFile(outfile) as f: 14 | contents = f.read('hello.txt') 15 | assert contents == 'hello world' 16 | assert f.namelist() == ['hello.txt'] 17 | 18 | 19 | def test_can_zip_recursive_contents(tmpdir): 20 | source = tmpdir.mkdir('sourcedir') 21 | source.join('hello.txt').write('hello world') 22 | subdir = source.mkdir('subdir') 23 | subdir.join('sub.txt').write('sub.txt') 24 | subdir.join('sub2.txt').write('sub2.txt') 25 | subsubdir = subdir.mkdir('subsubdir') 26 | subsubdir.join('leaf.txt').write('leaf.txt') 27 | 28 | outfile = str(tmpdir.join('out.zip')) 29 | utils.create_zip_file(source_dir=str(source), 30 | outfile=outfile) 31 | with zipfile.ZipFile(outfile) as f: 32 | assert f.namelist() == [ 33 | 'hello.txt', 34 | 'subdir/sub.txt', 35 | 'subdir/sub2.txt', 36 | 'subdir/subsubdir/leaf.txt', 37 | ] 38 | assert f.read('subdir/subsubdir/leaf.txt') == 'leaf.txt' 39 | 40 | 41 | def test_can_write_recorded_values(tmpdir): 42 | filename = str(tmpdir.join('deployed.json')) 43 | utils.record_deployed_values({'dev': {'deployed': 'foo'}}, filename) 44 | with open(filename, 'r') as f: 45 | assert json.load(f) == {'dev': {'deployed': 'foo'}} 46 | 47 | 48 | def test_can_merge_recorded_values(tmpdir): 49 | filename = str(tmpdir.join('deployed.json')) 50 | first = {'dev': {'deployed': 'values'}} 51 | second = {'prod': {'deployed': 'values'}} 52 | utils.record_deployed_values(first, filename) 53 | utils.record_deployed_values(second, filename) 54 | combined = first.copy() 55 | combined.update(second) 56 | with open(filename, 'r') as f: 57 | data = json.load(f) 58 | assert data == combined 59 | -------------------------------------------------------------------------------- /tests/unit/test_pipeline.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from chalice import pipeline 4 | 5 | 6 | @pytest.fixture 7 | def pipeline_gen(): 8 | return pipeline.CreatePipelineTemplate() 9 | 10 | 11 | def test_app_name_in_param_default(pipeline_gen): 12 | template = pipeline_gen.create_template('appname') 13 | assert template['Parameters']['ApplicationName']['Default'] == 'appname' 14 | 15 | 16 | def test_source_repo_resource(pipeline_gen): 17 | template = {} 18 | pipeline.SourceRepository().add_to_template(template) 19 | assert template == { 20 | "Resources": { 21 | "SourceRepository": { 22 | "Type": "AWS::CodeCommit::Repository", 23 | "Properties": { 24 | "RepositoryName": { 25 | "Ref": "ApplicationName" 26 | }, 27 | "RepositoryDescription": { 28 | "Fn::Sub": "Source code for ${ApplicationName}" 29 | } 30 | } 31 | } 32 | }, 33 | "Outputs": { 34 | "SourceRepoURL": { 35 | "Value": { 36 | "Fn::GetAtt": "SourceRepository.CloneUrlHttp" 37 | } 38 | } 39 | } 40 | } 41 | 42 | 43 | def test_codebuild_resource(pipeline_gen): 44 | template = {} 45 | pipeline.CodeBuild().add_to_template(template) 46 | resources = template['Resources'] 47 | assert 'ApplicationBucket' in resources 48 | assert 'CodeBuildRole' in resources 49 | assert 'CodeBuildPolicy' in resources 50 | assert 'AppPackageBuild' in resources 51 | assert resources['ApplicationBucket'] == {'Type': 'AWS::S3::Bucket'} 52 | assert template['Outputs']['CodeBuildRoleArn'] == { 53 | 'Value': {'Fn::GetAtt': 'CodeBuildRole.Arn'} 54 | } 55 | 56 | 57 | def test_codepipeline_resource(pipeline_gen): 58 | template = {} 59 | pipeline.CodePipeline().add_to_template(template) 60 | resources = template['Resources'] 61 | assert 'AppPipeline' in resources 62 | assert 'ArtifactBucketStore' in resources 63 | assert 'CodePipelineRole' in resources 64 | assert 'CFNDeployRole' in resources 65 | # Some basic sanity checks 66 | resources['AppPipeline']['Type'] == 'AWS::CodePipeline::Pipeline' 67 | resources['ArtifactBucketStore']['Type'] == 'AWS::S3::Bucket' 68 | resources['CodePipelineRole']['Type'] == 'AWS::IAM::Role' 69 | resources['CFNDeployRole']['Type'] == 'AWS::IAM::Role' 70 | -------------------------------------------------------------------------------- /tests/integration/testapp/app.py: -------------------------------------------------------------------------------- 1 | from chalice import Chalice, BadRequestError, NotFoundError, Response 2 | 3 | import urlparse 4 | # This is a test app that is used by integration tests. 5 | # This app exercises all the major features of chalice 6 | # and helps prevent regressions. 7 | 8 | app = Chalice(app_name='smoketestapp') 9 | 10 | 11 | @app.route('/') 12 | def index(): 13 | return {'hello': 'world'} 14 | 15 | 16 | @app.route('/a/b/c/d/e/f/g') 17 | def nested_route(): 18 | return {'nested': True} 19 | 20 | 21 | @app.route('/path/{name}') 22 | def supports_path_params(name): 23 | return {'path': name} 24 | 25 | 26 | @app.route('/post', methods=['POST']) 27 | def supports_only_post(): 28 | return {'success': True} 29 | 30 | 31 | @app.route('/put', methods=['PUT']) 32 | def supports_only_put(): 33 | return {'success': True} 34 | 35 | 36 | @app.route('/jsonpost', methods=['POST']) 37 | def supports_post_body_as_json(): 38 | json_body = app.current_request.json_body 39 | return {'json_body': json_body} 40 | 41 | 42 | @app.route('/multimethod', methods=['GET', 'POST']) 43 | def multiple_methods(): 44 | return {'method': app.current_request.method} 45 | 46 | 47 | @app.route('/badrequest') 48 | def bad_request_error(): 49 | raise BadRequestError("Bad request.") 50 | 51 | 52 | @app.route('/notfound') 53 | def not_found_error(): 54 | raise NotFoundError("Not found") 55 | 56 | 57 | @app.route('/arbitrary-error') 58 | def raise_arbitrary_error(): 59 | raise TypeError("Uncaught exception") 60 | 61 | 62 | @app.route('/formencoded', methods=['POST'], 63 | content_types=['application/x-www-form-urlencoded']) 64 | def form_encoded(): 65 | parsed = urlparse.parse_qs(app.current_request.raw_body) 66 | return { 67 | 'parsed': parsed 68 | } 69 | 70 | 71 | @app.route('/cors', methods=['GET', 'POST', 'PUT'], cors=True) 72 | def supports_cors(): 73 | # It doesn't really matter what we return here because 74 | # we'll be checking the response headers to verify CORS support. 75 | return {'cors': True} 76 | 77 | 78 | @app.route('/todict', methods=['GET']) 79 | def todict(): 80 | return app.current_request.to_dict() 81 | 82 | 83 | @app.route('/multifile') 84 | def multifile(): 85 | from chalicelib import MESSAGE 86 | return {"message": MESSAGE} 87 | 88 | 89 | @app.route('/custom-response', methods=['GET']) 90 | def custom_response(): 91 | return Response(status_code=204, body='', 92 | headers={'Content-Type': 'text/plain'}) 93 | -------------------------------------------------------------------------------- /docs/source/topics/multifile.rst: -------------------------------------------------------------------------------- 1 | Multifile Support 2 | ================= 3 | 4 | The ``app.py`` file contains all of your view functions and route 5 | information, but you don't have to keep all of your application 6 | code in your ``app.py`` file. 7 | 8 | As your application grows, you may reach out a point where you'd 9 | prefer to structure your application in multiple files. 10 | You can create a ``chalicelib/`` directory, and anything 11 | in that directory is recursively included in the deployment 12 | package. This means that you can have files besides just 13 | ``.py`` files in ``chalicelib/``, including ``.json`` files 14 | for config, or any kind of binary assets. 15 | 16 | Let's take a look at a few examples. 17 | 18 | Consider the following app directory structure layout:: 19 | 20 | . 21 | ├── app.py 22 | ├── chalicelib 23 | │   └── __init__.py 24 | └── requirements.txt 25 | 26 | Where ``chalicelib/__init__.py`` contains: 27 | 28 | .. code-block:: python 29 | 30 | MESSAGE = 'world' 31 | 32 | 33 | and the ``app.py`` file contains: 34 | 35 | .. code-block:: python 36 | :linenos: 37 | :emphasize-lines: 2 38 | 39 | from chalice import Chalice 40 | from chalicelib import MESSAGE 41 | 42 | app = Chalice(app_name="multifile") 43 | 44 | @app.route("/") 45 | def index(): 46 | return {"hello": MESSAGE} 47 | 48 | 49 | Note in line 2 we're importing the ``MESSAGE`` variable from 50 | the ``chalicelib`` package, which is a top level directory 51 | in our project. We've created a ``chalicelib/__init__.py`` 52 | file which turns the ``chalicelib`` directory into a python 53 | package. 54 | 55 | We can also use this directory to store config data. Consider 56 | this app structure layout:: 57 | 58 | 59 | . 60 | ├── app.py 61 | ├── chalicelib 62 | │   └── config.json 63 | └── requirements.txt 64 | 65 | 66 | With ``chalicelib/config.json`` containing:: 67 | 68 | {"message": "world"} 69 | 70 | 71 | In our ``app.py`` code, we can load and use our config file: 72 | 73 | .. code-block:: python 74 | :linenos: 75 | 76 | import os 77 | import json 78 | 79 | from chalice import Chalice 80 | 81 | app = Chalice(app_name="multifile") 82 | 83 | filename = os.path.join( 84 | os.path.dirname(__file__), 'chalicelib', 'config.json') 85 | with open(filename) as f: 86 | config = json.load(f) 87 | 88 | @app.route("/") 89 | def index(): 90 | # We can access ``config`` here if we want. 91 | return {"hello": config['message']} 92 | -------------------------------------------------------------------------------- /chalice/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import zipfile 3 | import json 4 | 5 | from typing import IO, Dict, Any # noqa 6 | 7 | 8 | def record_deployed_values(deployed_values, filename): 9 | # type: (Dict[str, str], str) -> None 10 | """Record deployed values to a JSON file. 11 | 12 | This allows subsequent deploys to lookup previously deployed values. 13 | 14 | """ 15 | final_values = {} # type: Dict[str, Any] 16 | if os.path.isfile(filename): 17 | with open(filename, 'r') as f: 18 | final_values = json.load(f) 19 | final_values.update(deployed_values) 20 | with open(filename, 'wb') as f: 21 | f.write(json.dumps(final_values, indent=2, separators=(',', ': '))) 22 | 23 | 24 | def create_zip_file(source_dir, outfile): 25 | # type: (str, str) -> None 26 | """Create a zip file from a source input directory. 27 | 28 | This function is intended to be an equivalent to 29 | `zip -r`. You give it a source directory, `source_dir`, 30 | and it will recursively zip up the files into a zipfile 31 | specified by the `outfile` argument. 32 | 33 | """ 34 | with zipfile.ZipFile(outfile, 'w', 35 | compression=zipfile.ZIP_DEFLATED) as z: 36 | for root, _, filenames in os.walk(source_dir): 37 | for filename in filenames: 38 | full_name = os.path.join(root, filename) 39 | archive_name = os.path.relpath(full_name, source_dir) 40 | z.write(full_name, archive_name) 41 | 42 | 43 | class OSUtils(object): 44 | def open(self, filename, mode): 45 | # type: (str, str) -> IO 46 | return open(filename, mode) 47 | 48 | def remove_file(self, filename): 49 | # type: (str) -> None 50 | """Remove a file, noop if file does not exist.""" 51 | # Unlike os.remove, if the file does not exist, 52 | # then this method does nothing. 53 | try: 54 | os.remove(filename) 55 | except OSError: 56 | pass 57 | 58 | def file_exists(self, filename): 59 | # type: (str) -> bool 60 | return os.path.isfile(filename) 61 | 62 | def get_file_contents(self, filename, binary=True): 63 | # type: (str, bool) -> str 64 | if binary: 65 | mode = 'rb' 66 | else: 67 | mode = 'r' 68 | with open(filename, mode) as f: 69 | return f.read() 70 | 71 | def set_file_contents(self, filename, contents, binary=True): 72 | # type: (str, str, bool) -> None 73 | if binary: 74 | mode = 'wb' 75 | else: 76 | mode = 'w' 77 | with open(filename, mode) as f: 78 | f.write(contents) 79 | -------------------------------------------------------------------------------- /docs/source/topics/stages.rst: -------------------------------------------------------------------------------- 1 | Chalice Stages 2 | ============== 3 | 4 | Chalice has the concept of stages, which are completely 5 | separate sets of AWS resources. When you first create a chalice 6 | project and run commands such as ``chalice deploy`` and ``chalice url``, 7 | you don't have to specify any stage values or stage configuration. 8 | This is because chalice will use a stage named ``dev`` by default. 9 | 10 | You may eventually want to have multiple stages of your application. A 11 | common configuration would be to have a ``dev``, ``beta`` and ``prod`` 12 | stage. A ``dev`` stage would be used by developers to test out new 13 | features. Completed features would be deployed to ``beta``, and the 14 | ``prod`` stage would be used for serving production traffic. 15 | 16 | Chalice can help you manage this. 17 | 18 | To create a new chalice stage, specify the ``--stage`` argument. 19 | If the stage does not exist yet, it will be created for you:: 20 | 21 | $ chalice deploy --stage prod 22 | 23 | By creating a new chalice stage, a new API Gateway rest API, Lambda 24 | function, and potentially (depending on config settings) a new IAM role 25 | will be created for you. 26 | 27 | 28 | Example 29 | ------- 30 | 31 | Let's say we have a new app:: 32 | 33 | $ chalice new-project myapp 34 | $ cd myapp 35 | $ chalice deploy 36 | ... 37 | https://mmnkdi.execute-api.us-west-2.amazonaws.com/dev/ 38 | 39 | We've just created our first stage, ``dev``. We can iterate on our 40 | application and continue to run ``chalice deploy`` to deploy our code 41 | to the ``dev`` stage. Let's say we want to now create a ``prod`` stage. 42 | To do this, we can run:: 43 | 44 | $ chalice deploy --stage prod 45 | ... 46 | https://wk9fhx.execute-api.us-west-2.amazonaws.com/dev/ 47 | 48 | We now have two completely separate rest APIs:: 49 | 50 | $ chalice url --stage dev 51 | https://mmnkdi.execute-api.us-west-2.amazonaws.com/dev/ 52 | 53 | $ chalice url --stage prod 54 | https://wk9fhx.execute-api.us-west-2.amazonaws.com/dev/ 55 | 56 | Additionally, we can see all our deployed values by looking 57 | at the ``.chalice/deployed.json`` file:: 58 | 59 | $ cat .chalice/deployed.json 60 | { 61 | "dev": { 62 | "region": "us-west-2", 63 | "api_handler_name": "myapp-dev", 64 | "api_handler_arn": "arn:aws:lambda:...:function:myapp", 65 | "rest_api_id": "wk9fhx", 66 | "chalice_version": "0.7.0", 67 | "api_gateway_stage": "dev", 68 | "backend": "api" 69 | }, 70 | "prod": { 71 | "rest_api_id": "mmnkdi", 72 | "chalice_version": "0.7.0", 73 | "region": "us-west-2", 74 | "backend": "api", 75 | "api_handler_name": "myapp-prod", 76 | "api_handler_arn": "arn:aws:lambda:...:function:myapp-prod", 77 | "api_gateway_stage": "dev" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /docs/source/topics/logging.rst: -------------------------------------------------------------------------------- 1 | Logging 2 | ======= 3 | 4 | You have several options for logging in your 5 | application. You can use any of the options 6 | available to lambda functions as outlined 7 | in the 8 | `AWS Lambda Docs `_. 9 | The simplest option is to just use print statements. 10 | Anything you print will be accessible in cloudwatch logs 11 | as well as in the output of the ``chalice logs`` command. 12 | 13 | In addition to using the stdlib ``logging`` module directly, 14 | the framework offers a preconfigured logger designed to work 15 | nicely with Lambda. This is offered purely as a convenience, 16 | you can use ``print`` or the ``logging`` module directly if you prefer. 17 | 18 | You can access this logger via the ``app.log`` 19 | attribute, which is a a logger specifically for your application. 20 | This attribute is an instance of ``logging.getLogger(your_app_name_)`` 21 | that's been preconfigured with reasonable defaults: 22 | 23 | * StreamHandler associated with ``sys.stdout``. 24 | * Log level set to ``logging.ERROR`` by default. 25 | You can also manually set the logging level by setting 26 | ``app.log.setLevel(logging.DEBUG)``. 27 | * A logging formatter that displays the app name, level name, 28 | and message. 29 | 30 | 31 | Examples 32 | -------- 33 | 34 | In the following application, we're using the application logger 35 | to emit two log messages, one at ``DEBUG`` and one at the ``ERROR`` 36 | level: 37 | 38 | .. code-block:: python 39 | 40 | from chalice import Chalice 41 | 42 | app = Chalice(app_name='demolog') 43 | 44 | 45 | @app.route('/') 46 | def index(): 47 | app.log.debug("This is a debug statement") 48 | app.log.error("This is an error statement") 49 | return {'hello': 'world'} 50 | 51 | 52 | If we make a request to this endpoint, and then look at 53 | ``chalice logs`` we'll see the following log message:: 54 | 55 | 2016-11-06 20:24:25.490000 9d2a92 demolog - ERROR - This is an error statement 56 | 57 | As you can see, only the ``ERROR`` level log is emitted because 58 | the default log level is ``ERROR``. Also note the log message formatting. 59 | This is the default format that's been automatically configured. 60 | We can make a change to set our log level to debug: 61 | 62 | 63 | .. code-block:: python 64 | 65 | from chalice import Chalice 66 | 67 | app = Chalice(app_name='demolog') 68 | # Enable DEBUG logs. 69 | app.log.setLevel(logging.DEBUG) 70 | 71 | 72 | @app.route('/') 73 | def index(): 74 | app.log.debug("This is a debug statement") 75 | app.log.error("This is an error statement") 76 | return {'hello': 'world'} 77 | 78 | Now if we make a request to the ``/`` URL and look at the 79 | output of ``chalice logs``, we'll see the following log message:: 80 | 81 | 2016-11-07 12:29:15.714 431786 demolog - DEBUG - This is a debug statement 82 | 2016-11-07 12:29:15.714 431786 demolog - ERROR - This is an error statement 83 | 84 | 85 | As you can see here, both the debug and error log message are shown. 86 | -------------------------------------------------------------------------------- /chalice/app.pyi: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Any, Callable 2 | 3 | class ChaliceError(Exception): ... 4 | class ChaliceViewError(ChaliceError): 5 | __name__ = ... # type: str 6 | STATUS_CODE = ... # type: int 7 | class BadRequestError(ChaliceViewError): ... 8 | class UnauthorizedError(ChaliceViewError): ... 9 | class ForbiddenError(ChaliceViewError): ... 10 | class NotFoundError(ChaliceViewError): ... 11 | class ConflictError(ChaliceViewError): ... 12 | class TooManyRequestsError(ChaliceViewError): ... 13 | 14 | 15 | ALL_ERRORS = ... # type: List[ChaliceViewError] 16 | 17 | class Request: 18 | query_params = ... # type: Dict[str, str] 19 | headers = ... # type: Dict[str, str] 20 | uri_params = ... # type: Dict[str, str] 21 | method = ... # type: str 22 | body = ... # type: Any 23 | base64_body = ... # type: str 24 | context = ... # type: Dict[str, str] 25 | stage_vars = ... # type: Dict[str, str] 26 | 27 | def __init__( 28 | self, 29 | query_params: Dict[str, str], 30 | headers: Dict[str, str], 31 | uri_params: Dict[str, str], 32 | method: str, 33 | body: Any, 34 | base64_body: str, 35 | context: Dict[str, str], 36 | stage_vars: Dict[str, str]) -> None: ... 37 | def to_dict(self) -> Dict[Any, Any]: ... 38 | 39 | 40 | class Response: 41 | headers = ... # type: Dict[str, str] 42 | body = ... # type: Any 43 | status_code = ... # type: int 44 | 45 | def __init__(self, 46 | body: Any, 47 | headers: Dict[str, str], 48 | status_code: int) -> None: ... 49 | 50 | def to_dict(self) -> Dict[str, Any]: ... 51 | 52 | 53 | class RouteEntry(object): 54 | # TODO: How so I specify *args, where args is a tuple of strings. 55 | view_function = ... # type: Callable[..., Any] 56 | view_name = ... # type: str 57 | methods = ... # type: List[str] 58 | uri_pattern = ... # type: str 59 | authorizer_name = ... # type: str 60 | api_key_required = ... # type: bool 61 | content_types = ... # type: List[str] 62 | view_args = ... # type: List[str] 63 | cors = ... # type: bool 64 | 65 | def __init__(self, view_function: Callable[..., Any], 66 | view_name: str, path: str, methods: List[str], 67 | authorizer_name: str=None, 68 | api_key_required: bool=None, 69 | content_types: List[str]=None, 70 | cors: bool=False) -> None: ... 71 | 72 | def _parse_view_args(self) -> List[str]: ... 73 | 74 | def __eq__(self, other: object) -> bool: ... 75 | 76 | 77 | class Chalice(object): 78 | app_name = ... # type: str 79 | routes = ... # type: Dict[str, RouteEntry] 80 | current_request = ... # type: Request 81 | debug = ... # type: bool 82 | authorizers = ... # type: Dict[str, Dict[str, Any]] 83 | 84 | def __init__(self, app_name: str) -> None: ... 85 | 86 | def route(self, path: str, **kwargs: Any) -> Callable[..., Any]: ... 87 | def _add_route(self, path: str, view_func: Callable[..., Any], **kwargs: Any) -> None: ... 88 | def __call__(self, event: Any, context: Any) -> Any: ... 89 | def _get_view_function_response(self, 90 | view_function: Callable[..., Any], 91 | function_args: List[Any]) -> Response: ... 92 | -------------------------------------------------------------------------------- /tests/functional/cli/test_factory.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import json 4 | import logging 5 | 6 | import pytest 7 | from pytest import fixture 8 | 9 | from chalice.cli import factory 10 | from chalice.deploy.deployer import Deployer 11 | from chalice.config import Config 12 | 13 | 14 | @fixture 15 | def clifactory(tmpdir): 16 | appdir = tmpdir.mkdir('app') 17 | appdir.join('app.py').write( 18 | '# Test app\n' 19 | 'import chalice\n' 20 | 'app = chalice.Chalice(app_name="test")\n' 21 | ) 22 | chalice_dir = appdir.mkdir('.chalice') 23 | chalice_dir.join('config.json').write('{}') 24 | return factory.CLIFactory(str(appdir)) 25 | 26 | 27 | def assert_has_no_request_body_filter(log_name): 28 | log = logging.getLogger(log_name) 29 | assert not any( 30 | isinstance(f, factory.LargeRequestBodyFilter) for f in log.filters) 31 | 32 | 33 | def assert_request_body_filter_in_log(log_name): 34 | log = logging.getLogger(log_name) 35 | assert any( 36 | isinstance(f, factory.LargeRequestBodyFilter) for f in log.filters) 37 | 38 | 39 | def test_can_create_botocore_session(): 40 | session = factory.create_botocore_session() 41 | assert session.user_agent().startswith('aws-chalice/') 42 | 43 | 44 | def test_can_create_botocore_session_debug(): 45 | log_name = 'botocore.endpoint' 46 | assert_has_no_request_body_filter(log_name) 47 | 48 | factory.create_botocore_session(debug=True) 49 | 50 | assert_request_body_filter_in_log(log_name) 51 | assert logging.getLogger('').level == logging.DEBUG 52 | 53 | 54 | def test_can_create_botocore_session_cli_factory(clifactory): 55 | clifactory.profile = 'myprofile' 56 | session = clifactory.create_botocore_session() 57 | assert session.profile == 'myprofile' 58 | 59 | 60 | def test_can_create_default_deployer(clifactory): 61 | session = clifactory.create_botocore_session() 62 | deployer = clifactory.create_default_deployer(session, None) 63 | assert isinstance(deployer, Deployer) 64 | 65 | 66 | def test_can_create_config_obj(clifactory): 67 | obj = clifactory.create_config_obj() 68 | assert isinstance(obj, Config) 69 | 70 | 71 | def test_cant_load_config_obj_with_bad_project(clifactory): 72 | clifactory.project_dir = 'nowhere-asdfasdfasdfas' 73 | with pytest.raises(RuntimeError): 74 | clifactory.create_config_obj() 75 | 76 | 77 | def test_error_raised_on_unknown_config_version(clifactory): 78 | filename = os.path.join( 79 | clifactory.project_dir, '.chalice', 'config.json') 80 | with open(filename, 'w') as f: 81 | f.write(json.dumps({"version": "100.0"})) 82 | 83 | with pytest.raises(factory.UnknownConfigFileVersion): 84 | clifactory.create_config_obj() 85 | 86 | 87 | def test_filename_and_lineno_included_in_syntax_error(clifactory): 88 | filename = os.path.join(clifactory.project_dir, 'app.py') 89 | with open(filename, 'w') as f: 90 | f.write("this is a syntax error\n") 91 | # If this app has been previously imported in another app 92 | # we need to remove it from the cached modules to ensure 93 | # we get the syntax error on import. 94 | sys.modules.pop('app', None) 95 | with pytest.raises(RuntimeError) as excinfo: 96 | clifactory.load_chalice_app() 97 | message = str(excinfo.value) 98 | assert 'app.py' in message 99 | assert 'line 1' in message 100 | -------------------------------------------------------------------------------- /docs/source/topics/routing.rst: -------------------------------------------------------------------------------- 1 | Routing 2 | ======= 3 | 4 | The :meth:`Chalice.route` method is used to contruct which routes 5 | you want to create for your API. The concept is the same 6 | mechanism used by `Flask `__ and 7 | `bottle `__. 8 | You decorate a function with ``@app.route(...)``, and whenever 9 | a user requests that URL, the function you've decorated is called. 10 | For example, suppose you deployed this app: 11 | 12 | .. code-block:: python 13 | 14 | from chalice import Chalice 15 | 16 | app = Chalice(app_name='helloworld') 17 | 18 | 19 | @app.route('/') 20 | def index(): 21 | return {'view': 'index'} 22 | 23 | @app.route('/a') 24 | def a(): 25 | return {'view': 'a'} 26 | 27 | @app.route('/b') 28 | def b(): 29 | return {'view': 'b'} 30 | 31 | 32 | If you go to ``https://endpoint/``, the ``index()`` function would be called. 33 | If you went to ``https://endpoint/a`` and ``https://endpoint/b``, then the 34 | ``a()`` and ``b()`` function would be called, respectively. 35 | 36 | .. note:: 37 | 38 | Do not end your route paths with a trailing slash. If you do this, the 39 | ``chalice deploy`` command will raise a validation error. 40 | 41 | 42 | You can also create a route that captures part of the URL. This captured value 43 | will then be passed in as arguments to your view function: 44 | 45 | 46 | .. code-block:: python 47 | 48 | from chalice import Chalice 49 | 50 | app = Chalice(app_name='helloworld') 51 | 52 | 53 | @app.route('/users/{name}') 54 | def users(name): 55 | return {'name': name} 56 | 57 | 58 | If you then go to ``https://endpoint/users/james``, then the view function 59 | will be called as: ``users('james')``. The parameters are passed as 60 | positional parameters based on the order they appear in the URL. The argument 61 | names for the view function do not need to match the name of the captured 62 | argument: 63 | 64 | 65 | .. code-block:: python 66 | 67 | from chalice import Chalice 68 | 69 | app = Chalice(app_name='helloworld') 70 | 71 | 72 | @app.route('/a/{first}/b/{second}') 73 | def users(first_arg, second_arg): 74 | return {'first': first_arg, 'second': second_arg} 75 | 76 | 77 | Other Request Metadata 78 | ---------------------- 79 | 80 | The route path can only contain ``[a-zA-Z0-9._-]`` chars and curly braces for 81 | parts of the URL you want to capture. You do not need to model other parts of 82 | the request you want to capture, including headers and query strings. Within 83 | a view function, you can introspect the current request using the 84 | :attr:`app.current_request ` attribute. This also 85 | means you cannot control the routing based on query strings or headers. 86 | Here's an example for accessing query string data in a view function: 87 | 88 | .. code-block:: python 89 | 90 | from chalice import Chalice 91 | 92 | app = Chalice(app_name='helloworld') 93 | 94 | 95 | @app.route('/users/{name}') 96 | def users(name): 97 | result = {'name': name} 98 | if app.current_request.query_params.get('include-greeting') == 'true': 99 | result['greeting'] = 'Hello, %s' % name 100 | return result 101 | 102 | In the function above, if the user provides a ``?include-greeting=true`` in the 103 | HTTP request, then an additional ``greeting`` key will be returned:: 104 | 105 | $ http https://endpoint/dev/users/bob 106 | 107 | { 108 | "name": "bob" 109 | } 110 | 111 | $ http https://endpoint/dev/users/bob?include-greeting=true 112 | 113 | { 114 | "greeting": "Hello, bob", 115 | "name": "bob" 116 | } 117 | -------------------------------------------------------------------------------- /tests/unit/test_policy.py: -------------------------------------------------------------------------------- 1 | from chalice.policy import PolicyBuilder 2 | from chalice.policy import diff_policies 3 | 4 | 5 | def iam_policy(client_calls): 6 | builder = PolicyBuilder() 7 | policy = builder.build_policy_from_api_calls(client_calls) 8 | return policy 9 | 10 | 11 | def assert_policy_is(actual, expected): 12 | # Prune out the autogen's stuff we don't 13 | # care about. 14 | statements = actual['Statement'] 15 | for s in statements: 16 | del s['Sid'] 17 | assert expected == statements 18 | 19 | 20 | def test_single_call(): 21 | assert_policy_is(iam_policy({'dynamodb': set(['list_tables'])}), [{ 22 | 'Effect': 'Allow', 23 | 'Action': [ 24 | 'dynamodb:ListTables' 25 | ], 26 | 'Resource': [ 27 | '*', 28 | ] 29 | }]) 30 | 31 | 32 | def test_multiple_calls_in_same_service(): 33 | assert_policy_is(iam_policy({'dynamodb': set(['list_tables', 34 | 'describe_table'])}), [{ 35 | 'Effect': 'Allow', 36 | 'Action': [ 37 | 'dynamodb:DescribeTable', 38 | 'dynamodb:ListTables', 39 | ], 40 | 'Resource': [ 41 | '*', 42 | ] 43 | }]) 44 | 45 | 46 | def test_multiple_services_used(): 47 | client_calls = { 48 | 'dynamodb': set(['list_tables']), 49 | 'cloudformation': set(['create_stack']), 50 | } 51 | assert_policy_is(iam_policy(client_calls), [ 52 | { 53 | 'Effect': 'Allow', 54 | 'Action': [ 55 | 'cloudformation:CreateStack', 56 | ], 57 | 'Resource': [ 58 | '*', 59 | ] 60 | }, 61 | { 62 | 'Effect': 'Allow', 63 | 'Action': [ 64 | 'dynamodb:ListTables', 65 | ], 66 | 'Resource': [ 67 | '*', 68 | ] 69 | }, 70 | ]) 71 | 72 | 73 | def test_not_one_to_one_mapping(): 74 | client_calls = { 75 | 's3': set(['list_buckets', 'list_objects', 76 | 'create_multipart_upload']), 77 | } 78 | assert_policy_is(iam_policy(client_calls), [ 79 | { 80 | 'Effect': 'Allow', 81 | 'Action': [ 82 | 's3:ListAllMyBuckets', 83 | 's3:ListBucket', 84 | 's3:PutObject', 85 | ], 86 | 'Resource': [ 87 | '*', 88 | ] 89 | }, 90 | ]) 91 | 92 | 93 | def test_can_diff_policy_removed(): 94 | first = iam_policy({'s3': {'list_buckets', 'list_objects'}}) 95 | second = iam_policy({'s3': {'list_buckets'}}) 96 | assert diff_policies(first, second) == {'removed': {'s3:ListBucket'}} 97 | 98 | 99 | def test_can_diff_policy_added(): 100 | first = iam_policy({'s3': {'list_buckets'}}) 101 | second = iam_policy({'s3': {'list_buckets', 'list_objects'}}) 102 | assert diff_policies(first, second) == {'added': {'s3:ListBucket'}} 103 | 104 | 105 | def test_can_diff_multiple_services(): 106 | first = iam_policy({ 107 | 's3': {'list_buckets'}, 108 | 'dynamodb': {'create_table'}, 109 | 'cloudformation': {'create_stack', 'delete_stack'}, 110 | }) 111 | second = iam_policy({ 112 | 's3': {'list_buckets', 'list_objects'}, 113 | 'cloudformation': {'create_stack', 'update_stack'}, 114 | }) 115 | assert diff_policies(first, second) == { 116 | 'added': {'s3:ListBucket', 'cloudformation:UpdateStack'}, 117 | 'removed': {'cloudformation:DeleteStack', 'dynamodb:CreateTable'}, 118 | } 119 | 120 | 121 | def test_no_changes(): 122 | first = iam_policy({'s3': {'list_buckets', 'list_objects'}}) 123 | second = iam_policy({'s3': {'list_buckets', 'list_objects'}}) 124 | assert diff_policies(first, second) == {} 125 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import botocore.session 2 | from botocore.stub import Stubber 3 | import pytest 4 | from pytest import fixture 5 | 6 | 7 | def pytest_addoption(parser): 8 | parser.addoption('--skip-slow', action='store_true', 9 | help='Skip slow tests') 10 | 11 | 12 | class StubbedSession(botocore.session.Session): 13 | def __init__(self, *args, **kwargs): 14 | super(StubbedSession, self).__init__(*args, **kwargs) 15 | self._cached_clients = {} 16 | self._client_stubs = {} 17 | 18 | def create_client(self, service_name, *args, **kwargs): 19 | if service_name not in self._cached_clients: 20 | client = self._create_stubbed_client(service_name, *args, **kwargs) 21 | self._cached_clients[service_name] = client 22 | return self._cached_clients[service_name] 23 | 24 | def _create_stubbed_client(self, service_name, *args, **kwargs): 25 | client = super(StubbedSession, self).create_client( 26 | service_name, *args, **kwargs) 27 | stubber = StubBuilder(Stubber(client)) 28 | self._client_stubs[service_name] = stubber 29 | return client 30 | 31 | def stub(self, service_name): 32 | if service_name not in self._client_stubs: 33 | self.create_client(service_name) 34 | return self._client_stubs[service_name] 35 | 36 | def activate_stubs(self): 37 | for stub in self._client_stubs.values(): 38 | stub.activate() 39 | 40 | def verify_stubs(self): 41 | for stub in self._client_stubs.values(): 42 | stub.assert_no_pending_responses() 43 | 44 | 45 | class StubBuilder(object): 46 | def __init__(self, stub): 47 | self.stub = stub 48 | self.activated = False 49 | self.pending_args = {} 50 | 51 | def __getattr__(self, name): 52 | if self.activated: 53 | # I want to be strict here to guide common test behavior. 54 | # This helps encourage the "record" "replay" "verify" 55 | # idiom in traditional mock frameworks. 56 | raise RuntimeError("Stub has already been activated: %s, " 57 | "you must set up your stub calls before " 58 | "calling .activate()" % self.stub) 59 | if not name.startswith('_'): 60 | # Assume it's an API call. 61 | self.pending_args['operation_name'] = name 62 | return self 63 | 64 | def assert_no_pending_responses(self): 65 | self.stub.assert_no_pending_responses() 66 | 67 | def activate(self): 68 | self.activated = True 69 | self.stub.activate() 70 | 71 | def returns(self, response): 72 | self.pending_args['service_response'] = response 73 | # returns() is essentially our "build()" method and triggers 74 | # creations of a stub response creation. 75 | p = self.pending_args 76 | self.stub.add_response(p['operation_name'], 77 | expected_params=p['expected_params'], 78 | service_response=p['service_response']) 79 | # And reset the pending_args for the next stub creation. 80 | self.pending_args = {} 81 | 82 | def raises_error(self, error_code, message): 83 | p = self.pending_args 84 | self.stub.add_client_error(p['operation_name'], 85 | service_error_code=error_code, 86 | service_message=message) 87 | # Reset pending args for next expectation. 88 | self.pending_args = {} 89 | 90 | def __call__(self, **kwargs): 91 | self.pending_args['expected_params'] = kwargs 92 | return self 93 | 94 | 95 | @fixture 96 | def stubbed_session(): 97 | s = StubbedSession() 98 | return s 99 | 100 | 101 | @fixture 102 | def no_local_config(monkeypatch): 103 | """Ensure no local AWS configuration is used. 104 | 105 | This is useful for unit/functional tests so we 106 | can ensure that local configuration does not affect 107 | the results of the test. 108 | 109 | """ 110 | monkeypatch.setenv('AWS_DEFAULT_REGION', 'us-west-2') 111 | monkeypatch.setenv('AWS_ACCESS_KEY_ID', 'foo') 112 | monkeypatch.setenv('AWS_SECRET_ACCESS_KEY', 'bar') 113 | monkeypatch.delenv('AWS_PROFILE', raising=False) 114 | monkeypatch.delenv('AWS_DEFAULT_PROFILE', raising=False) 115 | # Ensure that the existing ~/.aws/{config,credentials} file 116 | # don't influence test results. 117 | monkeypatch.setenv('AWS_CONFIG_FILE', '/tmp/asdfasdfaf/does/not/exist') 118 | monkeypatch.setenv('AWS_SHARED_CREDENTIALS_FILE', 119 | '/tmp/asdfasdfaf/does/not/exist2') 120 | -------------------------------------------------------------------------------- /chalice/logs.py: -------------------------------------------------------------------------------- 1 | """Module for inspecting chalice logs. 2 | 3 | This module provides APIs for searching, interacting 4 | with the logs generated by AWS Lambda. 5 | 6 | """ 7 | from typing import Any, Optional, Iterator, Dict # noqa 8 | import datetime 9 | 10 | 11 | class LogRetriever(object): 12 | def __init__(self, client, log_group_name): 13 | # type: (Any, str) -> None 14 | # client -> boto3.client('logs') 15 | self._client = client 16 | self._log_group_name = log_group_name 17 | 18 | @classmethod 19 | def create_from_arn(cls, client, lambda_arn): 20 | # type: (Any, str) -> LogRetriever 21 | """Create a LogRetriever from a client and lambda arn. 22 | 23 | :type client: botocore.client.Logs 24 | :param client: A ``logs`` client. 25 | 26 | :type lambda_arn: str 27 | :param lambda_arn: The ARN of the lambda function. 28 | 29 | :return: An instance of ``LogRetriever``. 30 | 31 | """ 32 | lambda_name = lambda_arn.split(':')[6] 33 | log_group_name = '/aws/lambda/%s' % lambda_name 34 | return cls(client, log_group_name) 35 | 36 | def _convert_to_datetime(self, integer_timestamp): 37 | # type: (int) -> datetime.datetime 38 | return datetime.datetime.fromtimestamp(integer_timestamp / 1000.0) 39 | 40 | def _is_lambda_message(self, event): 41 | # type: (Dict[str, Any]) -> bool 42 | # Lambda will also inject log messages into your log streams. 43 | # They look like: 44 | # START RequestId: guid Version: $LATEST 45 | # END RequestId: guid 46 | # REPORT RequestId: guid Duration: 0.35 ms Billed Duration: ... 47 | 48 | # By default, these message are included in retrieve_logs(). 49 | # But you can also request that retrieve_logs() filter out 50 | # these message so that we only include log messages generated 51 | # by your chalice app. 52 | msg = event['message'].strip() 53 | return msg.startswith(('START RequestId', 54 | 'END RequestId', 55 | 'REPORT RequestId')) 56 | 57 | def retrieve_logs(self, include_lambda_messages=True, max_entries=None): 58 | # type: (bool, Optional[int]) -> Iterator[Dict[str, Any]] 59 | """Retrieve logs from a log group. 60 | 61 | :type include_lambda_messages: boolean 62 | :param include_lambda_messages: Include logs generated by the AWS 63 | Lambda service. If this value is False, only chalice logs will be 64 | included. 65 | 66 | :type max_entries: int 67 | :param max_entries: Maximum number of log messages to include. 68 | 69 | :rtype: iterator 70 | :return: An iterator that yields event dicts. Each event 71 | dict has these keys: 72 | 73 | * logStreamName -> (string) The name of the log stream. 74 | * timestamp -> (datetime.datetime) - The timestamp for the msg. 75 | * message -> (string) The data contained in the log event. 76 | * ingestionTime -> (datetime.datetime) Ingestion time of event. 77 | * eventId -> (string) A unique identifier for this event. 78 | * logShortId -> (string) Short identifier for logStreamName. 79 | 80 | """ 81 | # TODO: Add support for startTime/endTime. 82 | paginator = self._client.get_paginator('filter_log_events') 83 | shown = 0 84 | for page in paginator.paginate(logGroupName=self._log_group_name, 85 | interleaved=True): 86 | events = page['events'] 87 | for event in events: 88 | if not include_lambda_messages and \ 89 | self._is_lambda_message(event): 90 | continue 91 | # timestamp is modeled as a 'long', so we'll 92 | # convert to a datetime to make it easier to use 93 | # in python. 94 | event['ingestionTime'] = self._convert_to_datetime( 95 | event['ingestionTime']) 96 | event['timestamp'] = self._convert_to_datetime( 97 | event['timestamp']) 98 | # logStreamName is: '2016/07/05/[id]hash' 99 | # We want to extract the hash portion and 100 | # provide a short identifier. 101 | identifier = event['logStreamName'] 102 | if ']' in identifier: 103 | index = identifier.find(']') 104 | identifier = identifier[index + 1:index + 7] 105 | event['logShortId'] = identifier 106 | yield event 107 | shown += 1 108 | if max_entries is not None and shown >= max_entries: 109 | return 110 | -------------------------------------------------------------------------------- /tests/unit/test_config.py: -------------------------------------------------------------------------------- 1 | from chalice.config import Config, DeployedResources 2 | 3 | 4 | def test_config_create_method(): 5 | c = Config.create(app_name='foo') 6 | assert c.app_name == 'foo' 7 | # Otherwise attributes default to None meaning 'not set'. 8 | assert c.profile is None 9 | assert c.api_gateway_stage is None 10 | 11 | 12 | def test_default_chalice_stage(): 13 | c = Config() 14 | assert c.chalice_stage == 'dev' 15 | 16 | 17 | def test_version_defaults_to_1_when_missing(): 18 | c = Config() 19 | assert c.config_file_version == '1.0' 20 | 21 | 22 | def test_default_value_of_manage_iam_role(): 23 | c = Config.create() 24 | assert c.manage_iam_role 25 | 26 | 27 | def test_manage_iam_role_explicitly_set(): 28 | c = Config.create(manage_iam_role=False) 29 | assert not c.manage_iam_role 30 | c = Config.create(manage_iam_role=True) 31 | assert c.manage_iam_role 32 | 33 | 34 | def test_can_chain_lookup(): 35 | user_provided_params = { 36 | 'api_gateway_stage': 'user_provided_params', 37 | } 38 | 39 | config_from_disk = { 40 | 'api_gateway_stage': 'config_from_disk', 41 | 'app_name': 'config_from_disk', 42 | } 43 | 44 | default_params = { 45 | 'api_gateway_stage': 'default_params', 46 | 'app_name': 'default_params', 47 | 'project_dir': 'default_params', 48 | } 49 | 50 | c = Config('dev', user_provided_params, config_from_disk, default_params) 51 | assert c.api_gateway_stage == 'user_provided_params' 52 | assert c.app_name == 'config_from_disk' 53 | assert c.project_dir == 'default_params' 54 | 55 | assert c.config_from_disk == config_from_disk 56 | 57 | 58 | def test_user_params_is_optional(): 59 | c = Config(config_from_disk={'api_gateway_stage': 'config_from_disk'}, 60 | default_params={'api_gateway_stage': 'default_params'}) 61 | assert c.api_gateway_stage == 'config_from_disk' 62 | 63 | 64 | def test_can_chain_chalice_stage_values(): 65 | disk_config = { 66 | 'api_gateway_stage': 'dev', 67 | 'stages': { 68 | 'dev': { 69 | }, 70 | 'prod': { 71 | 'api_gateway_stage': 'prod', 72 | 'iam_role_arn': 'foobar', 73 | 'manage_iam_role': False, 74 | } 75 | } 76 | } 77 | c = Config(chalice_stage='dev', 78 | config_from_disk=disk_config) 79 | assert c.api_gateway_stage == 'dev' 80 | assert c.manage_iam_role 81 | 82 | prod = Config(chalice_stage='prod', 83 | config_from_disk=disk_config) 84 | assert prod.api_gateway_stage == 'prod' 85 | assert prod.iam_role_arn == 'foobar' 86 | assert not prod.manage_iam_role 87 | 88 | 89 | def test_can_create_deployed_resource_from_dict(): 90 | d = DeployedResources.from_dict({ 91 | 'backend': 'api', 92 | 'api_handler_arn': 'arn', 93 | 'api_handler_name': 'name', 94 | 'rest_api_id': 'id', 95 | 'api_gateway_stage': 'stage', 96 | 'region': 'region', 97 | 'chalice_version': '1.0.0', 98 | }) 99 | assert d.backend == 'api' 100 | assert d.api_handler_arn == 'arn' 101 | assert d.api_handler_name == 'name' 102 | assert d.rest_api_id == 'id' 103 | assert d.api_gateway_stage == 'stage' 104 | assert d.region == 'region' 105 | assert d.chalice_version == '1.0.0' 106 | 107 | 108 | def test_environment_from_top_level(): 109 | config_from_disk = {'environment_variables': {"foo": "bar"}} 110 | c = Config('dev', config_from_disk=config_from_disk) 111 | assert c.environment_variables == config_from_disk['environment_variables'] 112 | 113 | 114 | def test_environment_from_stage_leve(): 115 | config_from_disk = { 116 | 'stages': { 117 | 'prod': { 118 | 'environment_variables': {"foo": "bar"} 119 | } 120 | } 121 | } 122 | c = Config('prod', config_from_disk=config_from_disk) 123 | assert c.environment_variables == \ 124 | config_from_disk['stages']['prod']['environment_variables'] 125 | 126 | 127 | def test_env_vars_chain_merge(): 128 | config_from_disk = { 129 | 'environment_variables': { 130 | 'top_level': 'foo', 131 | 'shared_key': 'from-top', 132 | }, 133 | 'stages': { 134 | 'prod': { 135 | 'environment_variables': { 136 | 'stage_var': 'bar', 137 | 'shared_key': 'from-stage', 138 | } 139 | } 140 | } 141 | } 142 | c = Config('prod', config_from_disk=config_from_disk) 143 | resolved = c.environment_variables 144 | assert resolved == { 145 | 'top_level': 'foo', 146 | 'stage_var': 'bar', 147 | 'shared_key': 'from-stage', 148 | } 149 | -------------------------------------------------------------------------------- /docs/source/api.rst: -------------------------------------------------------------------------------- 1 | Chalice 2 | ======= 3 | 4 | .. class:: Chalice(app_name) 5 | 6 | This class represents a chalice application. It provides: 7 | 8 | * The ability to register routes using the :meth:`route` method. 9 | * Within a few function, the ability to introspect the current 10 | request using the ``current_request`` attribute which is an instance 11 | of the :class:`Request` class. 12 | 13 | .. attribute:: current_request 14 | 15 | An object of type :class:`Request`. This value is only set when 16 | a view function is being called. This attribute can be used to 17 | introspect the current HTTP request. 18 | 19 | .. attribute:: debug 20 | 21 | A boolean value that enables debugging. By default, this value is 22 | ``False``. If debugging is true, then internal errors are returned back 23 | to the client. Additionally, debug log messages generated by the 24 | framework will show up in the cloudwatch logs. Example usage: 25 | 26 | .. code-block:: python 27 | 28 | from chalice import Chalice 29 | 30 | app = Chalice(app_name="appname") 31 | app.debug = True 32 | 33 | .. method:: route(path, \* , [methods [, name], authorization_type, authorizer_id, api_key_required]) 34 | 35 | Register a view function for a particular URI path. This method 36 | is intended to be used as a decorator for a view function. For example: 37 | 38 | .. code-block:: python 39 | 40 | from chalice import Chalice 41 | 42 | app = Chalice(app_name="appname") 43 | 44 | @app.route('/resource/{value}', methods=['PUT']) 45 | def viewfunction(value): 46 | pass 47 | 48 | 49 | :param str path: The path to associate with the view function. The 50 | ``path`` should only contain ``[a-zA-Z0-9._-]`` chars and curly 51 | braces for parts of the URL you would like to capture. The path 52 | should not end in a trailing slash, otherwise a validation error 53 | will be raised during deployment. 54 | 55 | :param list methods: Optional parameter that indicates which HTTP methods 56 | this view function should accept. By default, only ``GET`` requests 57 | are supported. If you only wanted to support ``POST`` requests, you 58 | would specify ``methods=['POST']``. If you support multiple HTTP 59 | methods in a single view function (``methods=['GET', 'POST']``), you 60 | can check the :attr:`app.current_request.method ` 61 | attribute to see which HTTP method was used when making the request. 62 | 63 | :param str name: Optional parameter to specify the name of the view 64 | function. You generally do not need to set this value. The name 65 | of the view function is used as the default value for the view name. 66 | 67 | :param str authorization_type: Optional parameter to specify the type 68 | of authorization used for the view. 69 | 70 | :param str authorizer_id: Optional parameter to specify the identifier 71 | of an Authorizer to use on this view, if the authorization_type is 72 | CUSTOM. 73 | 74 | :param boolean api_key_required: Optional parameter to specify whether 75 | the method required a valid ApiKey 76 | 77 | 78 | Request 79 | ======= 80 | 81 | .. class:: Request 82 | 83 | A class that represents the current request. This is mapped to 84 | the ``app.current_request`` object. 85 | 86 | .. attribute:: query_params 87 | 88 | A dict of the query params for the request. 89 | 90 | .. attribute:: headers 91 | 92 | A dict of the request headers. 93 | 94 | .. attribute:: uri_params 95 | 96 | A dict of the captured URI params. 97 | 98 | .. attribute:: method 99 | 100 | The HTTP method as a string. 101 | 102 | .. attribute:: json_body 103 | 104 | The parsed JSON body (``json.loads(raw_body)``). 105 | 106 | .. attribute:: raw_body 107 | 108 | The raw HTTP body as bytes. This is useful if you need to 109 | calculate a checksum of the HTTP body. 110 | 111 | .. attribute:: context 112 | 113 | A dict of additional context information. 114 | 115 | .. attribute:: stage_vars 116 | 117 | A dict of configuration for the API Gateway stage. 118 | 119 | 120 | Response 121 | ======== 122 | 123 | .. class:: Response(body, headers=None, status_code=200) 124 | 125 | A class that represents the response for the view function. You 126 | can optionally return an instance of this class from a view function if you 127 | want complete control over the returned HTTP response. 128 | 129 | .. versionadded:: 0.6.0 130 | 131 | .. attribute:: body 132 | 133 | The HTTP response body to send back. This value must be a string. 134 | 135 | .. attribute:: headers 136 | 137 | An optional dictionary of HTTP headers to send back. This is a dictionary 138 | of header name to header value, e.g ``{'Content-Type': 'text/plain'}`` 139 | 140 | .. attribute:: status_code 141 | 142 | The integer HTTP status code to send back in the HTTP response. 143 | -------------------------------------------------------------------------------- /docs/source/topics/packaging.rst: -------------------------------------------------------------------------------- 1 | App Packaging 2 | ============= 3 | 4 | In order to deploy your chalice app, a zip file is created that 5 | contains your application and all third party packages your application 6 | rqeuires. This file is used by AWS Lambda and is referred 7 | to as a deployment package. 8 | 9 | Chalice will automatically create this deployment package for you, and offers 10 | several features to make this easier to manage. Chalice allows you to 11 | clearly separate application specific modules and packages you are writing 12 | from 3rd party package dependencies. 13 | 14 | 15 | App Directories 16 | --------------- 17 | 18 | You have two options to structure application specific code/config: 19 | 20 | * **app.py** - This file includes all your route information and is always 21 | included in the deployment package. 22 | * **chalicelib/** - This directory (if it exists) is included in the 23 | deployment package. This is where you can add config files and additional 24 | application modules if you prefer not to have all your app code in the 25 | ``app.py`` file. 26 | 27 | See :doc:`multifile` for more info on the ``chalicelib/`` directory. Both the 28 | ``app.py`` and the ``chalicelib/`` directory are intended for code that you 29 | write yourself. 30 | 31 | 32 | 3rd Party Packages 33 | ------------------ 34 | 35 | There are two options for handling python package dependencies: 36 | 37 | * **requirements.txt** - During the packaging process, chalice will 38 | run ``pip install -r requirements.txt`` in a virtual environment 39 | and automatically install 3rd party python packages into the deployment 40 | package. 41 | * **vendor/** - The *contents* of this directory are automatically added to 42 | the top level of the deployment package. 43 | 44 | Chalice will also check for an optional ``vendor/`` directory in the project 45 | root directory. The contents of this directory are automatically included in 46 | the top level of the deployment package (see :ref:`package-examples` for 47 | specific examples). The ``vendor/`` directory is helpful in these scenarios: 48 | 49 | * You need to include custom packages or binary content that is not accessible 50 | via ``pip``. These may be internal packages that aren't public. 51 | * You need to use C extensions, and you're not developing on Linux. 52 | 53 | 54 | As a general rule of thumb, code that you write goes in either ``app.py`` or 55 | ``chalicelib/``, and dependencies are either specified in ``requirements.txt`` 56 | or placed in the ``vendor/`` directory. 57 | 58 | Examples 59 | -------- 60 | 61 | Suppose I have the following app structure:: 62 | 63 | . 64 | ├── app.py 65 | ├── chalicelib 66 | │   ├── __init__.py 67 | │   └── utils.py 68 | ├── requirements.txt 69 | └── vendor 70 | └── internalpackage 71 | └── __init__.py 72 | 73 | And the ``requirements.txt`` file had one requirement:: 74 | 75 | $ cat requirements.txt 76 | sortedcontainers==1.5.4 77 | 78 | Then the final deployment package directory structure would look like this:: 79 | 80 | . 81 | ├── app.py 82 | ├── chalicelib 83 | │   ├── __init__.py 84 | │   └── utils.py 85 | ├── internalpackage 86 | │   └── __init__.py 87 | └── sortedcontainers 88 | └── __init__.py 89 | 90 | 91 | This directory structure is then zipped up and sent to AWS Lambda during the 92 | deployment process. 93 | 94 | 95 | Psycopg2 Example 96 | ---------------- 97 | 98 | Below shows an example of how you can use the 99 | `psycopg2 `__ package in a chalice app. 100 | 101 | We're going to leverage the ``vendor/`` directory in order to use this 102 | package in our app. We can't use ``requirements.txt`` file because 103 | ``psycopg2`` has additional requirements: 104 | 105 | * It contains C extensions and if you're not developing on Amazon Linux, 106 | the binaries built on a dev machine will not match what's needed on AWS 107 | Lambda. 108 | * AWS Lambda does not have the ``libpq.so`` library available, so we need 109 | to build a custom version of ``psycopg2`` that has ``libpq.so`` statically 110 | linked. 111 | 112 | You can do this yourself by building `psycopg2 `__ 113 | on Amazon Linux with the ``static_libpq=1`` value set in the ``setup.cfg`` 114 | file. You can then copy/unzip the ``.whl`` file into the ``vendor/`` 115 | directory. 116 | 117 | There are also existing packages that have prebuilt this, including the 118 | 3rd party `awslambda-psycopg2 `__ 119 | package. If you wanted to use this 3rd party package you can follow these 120 | steps:: 121 | 122 | $ mkdir vendor 123 | $ git clone git@github.com:jkehler/awslambda-psycopg2.git 124 | $ cp -r awslambda-psycopg2/psycopg2 vendor/ 125 | $ rm -rf awslambda-psycopg2/ 126 | 127 | 128 | You should now have a directory that looks like this:: 129 | 130 | $ tree 131 | . 132 | ├── app.py 133 | ├── app.pyc 134 | ├── requirements.txt 135 | └── vendor 136 | └── psycopg2 137 | ├── __init__.py 138 | ├── _json.py 139 | ├── _psycopg.so 140 | .... 141 | 142 | 143 | In your ``app.py`` file you can now import ``psycopg2``, and this 144 | dependency will automatically be included when the ``chalice deploy`` 145 | command is run. 146 | -------------------------------------------------------------------------------- /chalice/policy.py: -------------------------------------------------------------------------------- 1 | """Policy generator based on allowed API calls. 2 | 3 | This module will take a set of API calls for services 4 | and make a best effort attempt to generate an IAM policy 5 | for you. 6 | 7 | """ 8 | import os 9 | import json 10 | import uuid 11 | 12 | from typing import Any, List, Dict, Set # noqa 13 | import botocore.session 14 | 15 | from chalice.constants import CLOUDWATCH_LOGS 16 | from chalice.utils import OSUtils # noqa 17 | from chalice.config import Config # noqa 18 | 19 | 20 | def policy_from_source_code(source_code): 21 | # type: (str) -> Dict[str, Any] 22 | from chalice.analyzer import get_client_calls_for_app 23 | client_calls = get_client_calls_for_app(source_code) 24 | builder = PolicyBuilder() 25 | policy = builder.build_policy_from_api_calls(client_calls) 26 | return policy 27 | 28 | 29 | def load_policy_actions(): 30 | # type: () -> Dict[str, str] 31 | policy_json = os.path.join( 32 | os.path.dirname(os.path.abspath(__file__)), 33 | 'policies.json') 34 | assert os.path.isfile(policy_json), policy_json 35 | with open(policy_json) as f: 36 | return json.loads(f.read()) 37 | 38 | 39 | def diff_policies(old, new): 40 | # type: (Dict[str, Any], Dict[str, Any]) -> Dict[str, Set[str]] 41 | diff = {} 42 | old_actions = _create_simple_format(old) 43 | new_actions = _create_simple_format(new) 44 | removed = old_actions - new_actions 45 | added = new_actions - old_actions 46 | if removed: 47 | diff['removed'] = removed 48 | if added: 49 | diff['added'] = added 50 | return diff 51 | 52 | 53 | def _create_simple_format(policy): 54 | # type: (Dict[str, Any]) -> Set[str] 55 | # This won't be sufficient is the analyzer is ever able 56 | # to work out which resources you're accessing. 57 | actions = set() # type: Set[str] 58 | for statement in policy['Statement']: 59 | actions.update(statement['Action']) 60 | return actions 61 | 62 | 63 | class AppPolicyGenerator(object): 64 | def __init__(self, osutils): 65 | # type: (OSUtils) -> None 66 | self._osutils = osutils 67 | 68 | def generate_policy(self, config): 69 | # type: (Config) -> Dict[str, Any] 70 | """Auto generate policy for an application.""" 71 | # Admittedly, this is pretty bare bones logic for the time 72 | # being. All it really does it work out, given a Config instance, 73 | # which files need to analyzed and then delegates to the 74 | # appropriately analyzer functions to do the real work. 75 | # This may change in the future. 76 | app_py = os.path.join(config.project_dir, 'app.py') 77 | assert self._osutils.file_exists(app_py) 78 | app_source = self._osutils.get_file_contents(app_py, binary=False) 79 | app_policy = policy_from_source_code(app_source) 80 | app_policy['Statement'].append(CLOUDWATCH_LOGS) 81 | return app_policy 82 | 83 | 84 | class PolicyBuilder(object): 85 | VERSION = '2012-10-17' 86 | 87 | def __init__(self, session=None, policy_actions=None): 88 | # type: (Any, Dict[str, str]) -> None 89 | if session is None: 90 | session = botocore.session.get_session() 91 | if policy_actions is None: 92 | policy_actions = load_policy_actions() 93 | self._session = session 94 | self._policy_actions = policy_actions 95 | 96 | def build_policy_from_api_calls(self, client_calls): 97 | # type: (Dict[str, Set[str]]) -> Dict[str, Any] 98 | statements = self._build_statements_from_client_calls(client_calls) 99 | policy = { 100 | 'Version': self.VERSION, 101 | 'Statement': statements 102 | } 103 | return policy 104 | 105 | def _build_statements_from_client_calls(self, client_calls): 106 | # type: (Dict[str, Set[str]]) -> List[Dict[str, Any]] 107 | statements = [] 108 | # client_calls = service_name -> set([method_calls]) 109 | for service in sorted(client_calls): 110 | if service not in self._policy_actions: 111 | print "Unsupported service:", service 112 | continue 113 | service_actions = self._policy_actions[service] 114 | method_calls = client_calls[service] 115 | # Next thing we need to do is convert the method_name to 116 | # MethodName. To this reliable we're going to use 117 | # botocore clients. 118 | client = self._session.create_client(service, 119 | region_name='us-east-1') 120 | mapping = client.meta.method_to_api_mapping 121 | actions = [service_actions[mapping[method_name]] for 122 | method_name in method_calls 123 | if mapping.get(method_name) in service_actions] 124 | actions.sort() 125 | if actions: 126 | statements.append({ 127 | 'Effect': 'Allow', 128 | 'Action': actions, 129 | # Probably impossible, but it would be nice 130 | # to even keep track of what resources are used 131 | # so we can create ARNs and further restrict the policies. 132 | 'Resource': ['*'], 133 | 'Sid': str(uuid.uuid4()).replace('-', ''), 134 | }) 135 | return statements 136 | -------------------------------------------------------------------------------- /tests/unit/test_package.py: -------------------------------------------------------------------------------- 1 | import mock 2 | 3 | import pytest 4 | from chalice.config import Config 5 | from chalice import package 6 | from chalice.deploy.deployer import ApplicationPolicyHandler 7 | from chalice.deploy.swagger import SwaggerGenerator 8 | 9 | 10 | @pytest.fixture 11 | def mock_swagger_generator(): 12 | return mock.Mock(spec=SwaggerGenerator) 13 | 14 | 15 | @pytest.fixture 16 | def mock_policy_generator(): 17 | return mock.Mock(spec=package.PreconfiguredPolicyGenerator) 18 | 19 | 20 | def test_can_create_app_packager(): 21 | config = Config() 22 | packager = package.create_app_packager(config) 23 | assert isinstance(packager, package.AppPackager) 24 | 25 | 26 | def test_can_create_app_packager_with_no_autogen(): 27 | # We can't actually observe a change here, but we want 28 | # to make sure the function can handle this param being 29 | # False. 30 | config = Config.create(autogen_policy=False) 31 | packager = package.create_app_packager(config) 32 | assert isinstance(packager, package.AppPackager) 33 | 34 | 35 | def test_preconfigured_policy_proxies(): 36 | policy_gen = mock.Mock(spec=ApplicationPolicyHandler) 37 | config = Config.create(project_dir='project_dir', autogen_policy=False) 38 | generator = package.PreconfiguredPolicyGenerator( 39 | config, policy_gen=policy_gen) 40 | policy_gen.generate_policy_from_app_source.return_value = { 41 | 'policy': True} 42 | policy = generator.generate_policy_from_app_source() 43 | policy_gen.generate_policy_from_app_source.assert_called_with(config) 44 | assert policy == {'policy': True} 45 | 46 | 47 | def test_sam_generates_sam_template_basic(sample_app, 48 | mock_swagger_generator, 49 | mock_policy_generator): 50 | p = package.SAMTemplateGenerator(mock_swagger_generator, 51 | mock_policy_generator) 52 | config = Config.create(chalice_app=sample_app, 53 | api_gateway_stage='dev') 54 | template = p.generate_sam_template(config, 'code-uri') 55 | # Verify the basic structure is in place. The specific parts 56 | # are validated in other tests. 57 | assert template['AWSTemplateFormatVersion'] == '2010-09-09' 58 | assert template['Transform'] == 'AWS::Serverless-2016-10-31' 59 | assert 'Outputs' in template 60 | assert 'Resources' in template 61 | 62 | 63 | def test_sam_injects_policy(sample_app, 64 | mock_swagger_generator, 65 | mock_policy_generator): 66 | p = package.SAMTemplateGenerator(mock_swagger_generator, 67 | mock_policy_generator) 68 | 69 | mock_policy_generator.generate_policy_from_app_source.return_value = { 70 | 'iam': 'policy', 71 | } 72 | config = Config.create(chalice_app=sample_app, 73 | api_gateway_stage='dev') 74 | template = p.generate_sam_template(config) 75 | assert template['Resources']['APIHandler']['Properties']['Policies'] == [{ 76 | 'iam': 'policy', 77 | }] 78 | 79 | 80 | def test_sam_injects_swagger_doc(sample_app, 81 | mock_swagger_generator, 82 | mock_policy_generator): 83 | p = package.SAMTemplateGenerator(mock_swagger_generator, 84 | mock_policy_generator) 85 | mock_swagger_generator.generate_swagger.return_value = { 86 | 'swagger': 'document' 87 | } 88 | config = Config.create(chalice_app=sample_app, 89 | api_gateway_stage='dev') 90 | template = p.generate_sam_template(config) 91 | properties = template['Resources']['RestAPI']['Properties'] 92 | assert properties['DefinitionBody'] == {'swagger': 'document'} 93 | 94 | 95 | def test_can_inject_environment_vars(sample_app, 96 | mock_swagger_generator, 97 | mock_policy_generator): 98 | p = package.SAMTemplateGenerator( 99 | mock_swagger_generator, mock_policy_generator) 100 | mock_swagger_generator.generate_swagger.return_value = { 101 | 'swagger': 'document' 102 | } 103 | config = Config.create( 104 | chalice_app=sample_app, 105 | api_gateway_stage='dev', 106 | environment_variables={ 107 | 'FOO': 'BAR' 108 | } 109 | ) 110 | template = p.generate_sam_template(config) 111 | properties = template['Resources']['APIHandler']['Properties'] 112 | assert 'Environment' in properties 113 | assert properties['Environment']['Variables'] == {'FOO': 'BAR'} 114 | 115 | 116 | def test_endpoint_url_reflects_apig_stage(sample_app, 117 | mock_swagger_generator, 118 | mock_policy_generator): 119 | p = package.SAMTemplateGenerator( 120 | mock_swagger_generator, mock_policy_generator) 121 | mock_swagger_generator.generate_swagger.return_value = { 122 | 'swagger': 'document' 123 | } 124 | config = Config.create( 125 | chalice_app=sample_app, 126 | api_gateway_stage='prod', 127 | ) 128 | template = p.generate_sam_template(config) 129 | endpoint_url = template['Outputs']['EndpointURL']['Value']['Fn::Sub'] 130 | assert endpoint_url == ( 131 | 'https://${RestAPI}.execute-api.${AWS::Region}.amazonaws.com/prod/') 132 | -------------------------------------------------------------------------------- /docs/source/topics/views.rst: -------------------------------------------------------------------------------- 1 | Views 2 | ===== 3 | 4 | A view function in chalice is the function attached to an 5 | ``@app.route()`` decorator. In the example below, ``index`` 6 | is the view function: 7 | 8 | .. code-block:: python 9 | 10 | from chalice import Chalice 11 | 12 | app = Chalice(app_name='helloworld') 13 | 14 | 15 | @app.route('/') 16 | def index(): 17 | return {'view': 'index'} 18 | 19 | 20 | View Function Parameters 21 | ------------------------ 22 | 23 | A view function's parameters correspond to the number of captured 24 | URL parameters specified in the ``@app.route`` call. In the example above, 25 | the route ``/`` specifies no captured parameters so the ``index`` view 26 | function accepts no parameters. However, in the view function below, 27 | a single URL parameter, ``{city}`` is specified, so the view function 28 | must accept a single parameter: 29 | 30 | 31 | .. code-block:: python 32 | 33 | from chalice import Chalice 34 | 35 | app = Chalice(app_name='helloworld') 36 | 37 | 38 | @app.route('/cities/{city}') 39 | def index(city): 40 | return {'city': city} 41 | 42 | 43 | This indicates that the value of ``{city}`` is variable, and whatever 44 | value is provided in the URL is passed to the ``index`` view function. 45 | For example:: 46 | 47 | GET /cities/seattle --> index('seattle') 48 | GET /cities/portland --> index('portland') 49 | 50 | 51 | If you want to access any other metdata of the incoming HTTP request, 52 | you can use the ``app.current_request`` property, which is an instance of 53 | the the :class:`Request` class. 54 | 55 | 56 | View Function Return Values 57 | --------------------------- 58 | 59 | The response returned back to the client depends on the behavior 60 | of the view function. There are several options available: 61 | 62 | * Returning an instance of :class:`Response`. This gives you 63 | complete control over what gets returned back to the customer. 64 | * Any other return value will be serialized as JSON and sent back 65 | as the response body with content type ``application/json``. 66 | * Any subclass of ``ChaliceViewError`` will result in an HTTP 67 | response being returned with the status code associated with that 68 | response, and a JSON response body containing a ``Code`` and a ``Message``. 69 | This is discussed in more detail below. 70 | * Any other exception raised will result in a 500 HTTP response. 71 | The body of that response depends on whether debug mode is enabled. 72 | 73 | 74 | Error Handling 75 | -------------- 76 | 77 | Chalice provides a built in set of exception classes that map to common 78 | HTTP errors including: 79 | 80 | * ``BadRequestError``- returns a status code of 400 81 | * ``UnauthorizedError``- returns a status code of 401 82 | * ``ForbiddenError``- returns a status code of 403 83 | * ``NotFoundError``- returns a status code of 404 84 | * ``ConflictError``- returns a status code of 409 85 | * ``TooManyRequestsError``- returns a status code of 429 86 | * ``ChaliceViewError``- returns a status code of 500 87 | 88 | You can raise these anywhere in your view functions and chalice will convert 89 | these to the appropriate HTTP response. The default chalice error responses 90 | will send the error back as ``application/json`` with the response body 91 | containing a ``Code`` corresponding to the exception class name and a 92 | ``Message`` key corresponding to the string provided when the exception 93 | was instantiated. For example: 94 | 95 | .. code-block:: python 96 | 97 | from chalice import Chalice 98 | from chalice import BadRequestError 99 | 100 | app = Chalice(app_name="badrequset") 101 | 102 | @app.route('/badrequest') 103 | def badrequest(): 104 | raise BadRequestError("This is a bad request") 105 | 106 | 107 | This view function will generate the following HTTP response:: 108 | 109 | $ http https://endpoint/dev/badrequest 110 | HTTP/1.1 400 Bad Request 111 | 112 | { 113 | "Code": "BadRequestError", 114 | "Message": "This is a bad request" 115 | } 116 | 117 | 118 | In addition to the built in chalice exceptions, you can use the 119 | :class:`Response` class to customize the HTTP errors if you prefer to 120 | either not have JSON error responses or customize the JSON response body 121 | for errors. For example: 122 | 123 | .. code-block:: python 124 | 125 | from chalice import Chalice, Response 126 | 127 | app = Chalice(app_name="badrequest") 128 | 129 | @app.route('/badrequest') 130 | def badrequest(): 131 | return Response(message='Plain text error message', 132 | headers={'Content-Type': 'text/plain'}, 133 | status_code=400) 134 | 135 | 136 | Usage Recommendations 137 | --------------------- 138 | 139 | If you want to return a JSON response body, just return the corresponding 140 | python types directly. You don't need to use the :class:`Response` class. 141 | Chalice will automatically convert this to a JSON HTTP response as a 142 | convenience for you. 143 | 144 | Use the :class:`Response` class when you want to return non-JSON content, or 145 | when you want to inject custom HTTP headers to your response. 146 | 147 | For errors, raise the built in ``ChaliceViewError`` subclasses (e.g 148 | ``BadRequestError``, ``NotFoundError``, ``ConflictError`` etc) when you 149 | want to return a HTTP error response with a preconfigured JSON body containing 150 | a ``Code`` and ``Message``. 151 | 152 | Use the :class:`Response` class when you want to customize the error responses 153 | to either return a different JSON error response body, or to return an HTTP 154 | response that's not ``application/json``. 155 | -------------------------------------------------------------------------------- /chalice/cli/factory.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import json 4 | import importlib 5 | import logging 6 | 7 | from botocore import session 8 | from typing import Any, Optional, Dict # noqa 9 | 10 | from chalice import __version__ as chalice_version 11 | from chalice.app import Chalice # noqa 12 | from chalice.config import Config 13 | from chalice.deploy import deployer 14 | from chalice.package import create_app_packager 15 | from chalice.package import AppPackager # noqa 16 | from chalice.constants import DEFAULT_STAGE_NAME 17 | 18 | 19 | def create_botocore_session(profile=None, debug=False): 20 | # type: (str, bool) -> session.Session 21 | s = session.Session(profile=profile) 22 | _add_chalice_user_agent(s) 23 | if debug: 24 | s.set_debug_logger('') 25 | _inject_large_request_body_filter() 26 | return s 27 | 28 | 29 | def _add_chalice_user_agent(session): 30 | # type: (session.Session) -> None 31 | suffix = '%s/%s' % (session.user_agent_name, session.user_agent_version) 32 | session.user_agent_name = 'aws-chalice' 33 | session.user_agent_version = chalice_version 34 | session.user_agent_extra = suffix 35 | 36 | 37 | def _inject_large_request_body_filter(): 38 | # type: () -> None 39 | log = logging.getLogger('botocore.endpoint') 40 | log.addFilter(LargeRequestBodyFilter()) 41 | 42 | 43 | class UnknownConfigFileVersion(Exception): 44 | def __init__(self, version): 45 | # type: (str) -> None 46 | super(UnknownConfigFileVersion, self).__init__( 47 | "Unknown version '%s' in config.json" % version) 48 | 49 | 50 | class LargeRequestBodyFilter(logging.Filter): 51 | def filter(self, record): 52 | # type: (Any) -> bool 53 | # Note: the proper type should be "logging.LogRecord", but 54 | # the typechecker complains about 'Invalid index type "int" for "dict"' 55 | # so we're using Any for now. 56 | if record.msg.startswith('Making request'): 57 | if record.args[0].name in ['UpdateFunctionCode', 'CreateFunction']: 58 | # When using the ZipFile argument (which is used in chalice), 59 | # the entire deployment package zip is sent as a base64 encoded 60 | # string. We don't want this to clutter the debug logs 61 | # so we don't log the request body for lambda operations 62 | # that have the ZipFile arg. 63 | record.args = (record.args[:-1] + 64 | ('(... omitted from logs due to size ...)',)) 65 | return True 66 | 67 | 68 | class CLIFactory(object): 69 | def __init__(self, project_dir, debug=False, profile=None): 70 | # type: (str, bool, Optional[str]) -> None 71 | self.project_dir = project_dir 72 | self.debug = debug 73 | self.profile = profile 74 | 75 | def create_botocore_session(self): 76 | # type: () -> session.Session 77 | return create_botocore_session(profile=self.profile, 78 | debug=self.debug) 79 | 80 | def create_default_deployer(self, session, prompter): 81 | # type: (session.Session, deployer.NoPrompt) -> deployer.Deployer 82 | return deployer.create_default_deployer( 83 | session=session, prompter=prompter) 84 | 85 | def create_config_obj(self, chalice_stage_name=DEFAULT_STAGE_NAME, 86 | autogen_policy=True, api_gateway_stage=None): 87 | # type: (str, bool, Optional[str]) -> Config 88 | user_provided_params = {} # type: Dict[str, Any] 89 | default_params = {'project_dir': self.project_dir} 90 | try: 91 | config_from_disk = self.load_project_config() 92 | except (OSError, IOError): 93 | raise RuntimeError("Unable to load the project config file. " 94 | "Are you sure this is a chalice project?") 95 | self._validate_config_from_disk(config_from_disk) 96 | app_obj = self.load_chalice_app() 97 | user_provided_params['chalice_app'] = app_obj 98 | if autogen_policy is not None: 99 | user_provided_params['autogen_policy'] = autogen_policy 100 | if self.profile is not None: 101 | user_provided_params['profile'] = self.profile 102 | if api_gateway_stage is not None: 103 | user_provided_params['api_gateway_stage'] = api_gateway_stage 104 | config = Config(chalice_stage_name, user_provided_params, 105 | config_from_disk, default_params) 106 | return config 107 | 108 | def _validate_config_from_disk(self, config): 109 | # type: (Dict[str, Any]) -> None 110 | string_version = config.get('version', '1.0') 111 | try: 112 | version = float(string_version) 113 | if version > 2.0: 114 | raise UnknownConfigFileVersion(string_version) 115 | except ValueError: 116 | raise UnknownConfigFileVersion(string_version) 117 | 118 | def create_app_packager(self, config): 119 | # type: (Config) -> AppPackager 120 | return create_app_packager(config) 121 | 122 | def load_chalice_app(self): 123 | # type: () -> Chalice 124 | if self.project_dir not in sys.path: 125 | sys.path.insert(0, self.project_dir) 126 | try: 127 | app = importlib.import_module('app') 128 | chalice_app = getattr(app, 'app') 129 | except SyntaxError as e: 130 | message = ( 131 | 'Unable to import your app.py file:\n\n' 132 | 'File "%s", line %s\n' 133 | ' %s\n' 134 | 'SyntaxError: %s' 135 | ) % (getattr(e, 'filename'), e.lineno, e.text, e.msg) 136 | raise RuntimeError(message) 137 | return chalice_app 138 | 139 | def load_project_config(self): 140 | # type: () -> Dict[str, Any] 141 | """Load the chalice config file from the project directory. 142 | 143 | :raise: OSError/IOError if unable to load the config file. 144 | 145 | """ 146 | config_file = os.path.join(self.project_dir, '.chalice', 'config.json') 147 | with open(config_file) as f: 148 | return json.loads(f.read()) 149 | -------------------------------------------------------------------------------- /chalice/constants.py: -------------------------------------------------------------------------------- 1 | 2 | # This is the version that's written to the config file 3 | # on a `chalice new-project`. It's also how chalice is able 4 | # to know when to warn you when changing behavior is introduced. 5 | CONFIG_VERSION = '2.0' 6 | 7 | 8 | TEMPLATE_APP = """\ 9 | from chalice import Chalice 10 | 11 | app = Chalice(app_name='%s') 12 | 13 | 14 | @app.route('/') 15 | def index(): 16 | return {'hello': 'world'} 17 | 18 | 19 | # The view function above will return {"hello": "world"} 20 | # whenever you make an HTTP GET request to '/'. 21 | # 22 | # Here are a few more examples: 23 | # 24 | # @app.route('/hello/{name}') 25 | # def hello_name(name): 26 | # # '/hello/james' -> {"hello": "james"} 27 | # return {'hello': name} 28 | # 29 | # @app.route('/users', methods=['POST']) 30 | # def create_user(): 31 | # # This is the JSON body the user sent in their POST request. 32 | # user_as_json = app.json_body 33 | # # Suppose we had some 'db' object that we used to 34 | # # read/write from our database. 35 | # # user_id = db.create_user(user_as_json) 36 | # return {'user_id': user_id} 37 | # 38 | # See the README documentation for more examples. 39 | # 40 | """ 41 | 42 | 43 | GITIGNORE = """\ 44 | .chalice/deployments/ 45 | .chalice/venv/ 46 | """ 47 | 48 | DEFAULT_STAGE_NAME = 'dev' 49 | 50 | 51 | LAMBDA_TRUST_POLICY = { 52 | "Version": "2012-10-17", 53 | "Statement": [{ 54 | "Sid": "", 55 | "Effect": "Allow", 56 | "Principal": { 57 | "Service": "lambda.amazonaws.com" 58 | }, 59 | "Action": "sts:AssumeRole" 60 | }] 61 | } 62 | 63 | 64 | CLOUDWATCH_LOGS = { 65 | "Effect": "Allow", 66 | "Action": [ 67 | "logs:CreateLogGroup", 68 | "logs:CreateLogStream", 69 | "logs:PutLogEvents" 70 | ], 71 | "Resource": "arn:aws:logs:*:*:*" 72 | } 73 | 74 | 75 | CODEBUILD_POLICY = { 76 | "Version": "2012-10-17", 77 | # This is the policy straight from the console. 78 | "Statement": [ 79 | { 80 | "Action": [ 81 | "logs:CreateLogGroup", 82 | "logs:CreateLogStream", 83 | "logs:PutLogEvents" 84 | ], 85 | "Resource": "*", 86 | "Effect": "Allow" 87 | }, 88 | { 89 | "Action": [ 90 | "s3:GetObject", 91 | "s3:GetObjectVersion", 92 | "s3:PutObject" 93 | ], 94 | "Resource": "arn:aws:s3:::*", 95 | "Effect": "Allow" 96 | } 97 | ] 98 | } 99 | 100 | CODEPIPELINE_POLICY = { 101 | "Version": "2012-10-17", 102 | # Also straight from the console setup. 103 | "Statement": [ 104 | { 105 | "Action": [ 106 | "s3:GetObject", 107 | "s3:GetObjectVersion", 108 | "s3:GetBucketVersioning" 109 | ], 110 | "Resource": "*", 111 | "Effect": "Allow" 112 | }, 113 | { 114 | "Action": [ 115 | "s3:PutObject" 116 | ], 117 | "Resource": [ 118 | "arn:aws:s3:::codepipeline*", 119 | "arn:aws:s3:::elasticbeanstalk*" 120 | ], 121 | "Effect": "Allow" 122 | }, 123 | { 124 | "Action": [ 125 | "codecommit:CancelUploadArchive", 126 | "codecommit:GetBranch", 127 | "codecommit:GetCommit", 128 | "codecommit:GetUploadArchiveStatus", 129 | "codecommit:UploadArchive" 130 | ], 131 | "Resource": "*", 132 | "Effect": "Allow" 133 | }, 134 | { 135 | "Action": [ 136 | "codedeploy:CreateDeployment", 137 | "codedeploy:GetApplicationRevision", 138 | "codedeploy:GetDeployment", 139 | "codedeploy:GetDeploymentConfig", 140 | "codedeploy:RegisterApplicationRevision" 141 | ], 142 | "Resource": "*", 143 | "Effect": "Allow" 144 | }, 145 | { 146 | "Action": [ 147 | "elasticbeanstalk:*", 148 | "ec2:*", 149 | "elasticloadbalancing:*", 150 | "autoscaling:*", 151 | "cloudwatch:*", 152 | "s3:*", 153 | "sns:*", 154 | "cloudformation:*", 155 | "rds:*", 156 | "sqs:*", 157 | "ecs:*", 158 | "iam:PassRole" 159 | ], 160 | "Resource": "*", 161 | "Effect": "Allow" 162 | }, 163 | { 164 | "Action": [ 165 | "lambda:InvokeFunction", 166 | "lambda:ListFunctions" 167 | ], 168 | "Resource": "*", 169 | "Effect": "Allow" 170 | }, 171 | { 172 | "Action": [ 173 | "opsworks:CreateDeployment", 174 | "opsworks:DescribeApps", 175 | "opsworks:DescribeCommands", 176 | "opsworks:DescribeDeployments", 177 | "opsworks:DescribeInstances", 178 | "opsworks:DescribeStacks", 179 | "opsworks:UpdateApp", 180 | "opsworks:UpdateStack" 181 | ], 182 | "Resource": "*", 183 | "Effect": "Allow" 184 | }, 185 | { 186 | "Action": [ 187 | "cloudformation:CreateStack", 188 | "cloudformation:DeleteStack", 189 | "cloudformation:DescribeStacks", 190 | "cloudformation:UpdateStack", 191 | "cloudformation:CreateChangeSet", 192 | "cloudformation:DeleteChangeSet", 193 | "cloudformation:DescribeChangeSet", 194 | "cloudformation:ExecuteChangeSet", 195 | "cloudformation:SetStackPolicy", 196 | "cloudformation:ValidateTemplate", 197 | "iam:PassRole" 198 | ], 199 | "Resource": "*", 200 | "Effect": "Allow" 201 | }, 202 | { 203 | "Action": [ 204 | "codebuild:BatchGetBuilds", 205 | "codebuild:StartBuild" 206 | ], 207 | "Resource": "*", 208 | "Effect": "Allow" 209 | } 210 | ] 211 | } 212 | -------------------------------------------------------------------------------- /tests/unit/deploy/test_swagger.py: -------------------------------------------------------------------------------- 1 | from chalice.deploy.swagger import SwaggerGenerator 2 | 3 | from pytest import fixture 4 | 5 | @fixture 6 | def swagger_gen(): 7 | return SwaggerGenerator(region='us-west-2', 8 | lambda_arn='lambda_arn') 9 | 10 | 11 | def test_can_produce_swagger_top_level_keys(sample_app, swagger_gen): 12 | swagger_doc = swagger_gen.generate_swagger(sample_app) 13 | assert swagger_doc['swagger'] == '2.0' 14 | assert swagger_doc['info']['title'] == 'sample' 15 | assert swagger_doc['schemes'] == ['https'] 16 | assert '/' in swagger_doc['paths'], swagger_doc['paths'] 17 | index_config = swagger_doc['paths']['/'] 18 | assert 'get' in index_config 19 | 20 | 21 | def test_can_produce_doc_for_method(sample_app, swagger_gen): 22 | doc = swagger_gen.generate_swagger(sample_app) 23 | single_method = doc['paths']['/']['get'] 24 | assert single_method['consumes'] == ['application/json'] 25 | assert single_method['produces'] == ['application/json'] 26 | # 'responses' is validated in a separate test, 27 | # it's all boilerplate anyways. 28 | # Same for x-amazon-apigateway-integration. 29 | 30 | 31 | def test_apigateway_integration_generation(sample_app, swagger_gen): 32 | doc = swagger_gen.generate_swagger(sample_app) 33 | single_method = doc['paths']['/']['get'] 34 | apig_integ = single_method['x-amazon-apigateway-integration'] 35 | assert apig_integ['passthroughBehavior'] == 'when_no_match' 36 | assert apig_integ['httpMethod'] == 'POST' 37 | assert apig_integ['type'] == 'aws_proxy' 38 | assert apig_integ['uri'] == ( 39 | "arn:aws:apigateway:us-west-2:lambda:path" 40 | "/2015-03-31/functions/lambda_arn/invocations" 41 | ) 42 | assert 'responses' in apig_integ 43 | responses = apig_integ['responses'] 44 | assert responses['default'] == {'statusCode': '200'} 45 | 46 | 47 | def test_can_add_url_captures_to_params(sample_app, swagger_gen): 48 | @sample_app.route('/path/{capture}') 49 | def foo(name): 50 | return {} 51 | 52 | doc = swagger_gen.generate_swagger(sample_app) 53 | single_method = doc['paths']['/path/{capture}']['get'] 54 | apig_integ = single_method['x-amazon-apigateway-integration'] 55 | assert 'parameters' in apig_integ 56 | assert apig_integ['parameters'] == [ 57 | {'name': "capture", "in": "path", "required": True, "type": "string"} 58 | ] 59 | 60 | 61 | def test_can_add_multiple_http_methods(sample_app, swagger_gen): 62 | @sample_app.route('/multimethod', methods=['GET', 'POST']) 63 | def multiple_methods(): 64 | pass 65 | 66 | doc = swagger_gen.generate_swagger(sample_app) 67 | view_config = doc['paths']['/multimethod'] 68 | assert 'get' in view_config 69 | assert 'post' in view_config 70 | assert view_config['get'] == view_config['post'] 71 | 72 | 73 | def test_can_add_preflight_cors(sample_app, swagger_gen): 74 | @sample_app.route('/cors', methods=['GET', 'POST'], cors=True) 75 | def cors_request(): 76 | pass 77 | 78 | doc = swagger_gen.generate_swagger(sample_app) 79 | view_config = doc['paths']['/cors'] 80 | # We should add an OPTIONS preflight request automatically. 81 | assert 'options' in view_config, ( 82 | 'Preflight OPTIONS method not added to CORS view') 83 | options = view_config['options'] 84 | expected_response_params = { 85 | 'method.response.header.Access-Control-Allow-Methods': ( 86 | "'GET,POST,OPTIONS'"), 87 | 'method.response.header.Access-Control-Allow-Headers': ( 88 | "'Content-Type,X-Amz-Date,Authorization," 89 | "X-Api-Key,X-Amz-Security-Token'"), 90 | 'method.response.header.Access-Control-Allow-Origin': "'*'", 91 | } 92 | assert options == { 93 | 'consumes': ['application/json'], 94 | 'produces': ['application/json'], 95 | 'responses': { 96 | '200': { 97 | 'description': '200 response', 98 | 'schema': { 99 | '$ref': '#/definitions/Empty' 100 | }, 101 | 'headers': { 102 | 'Access-Control-Allow-Origin': {'type': 'string'}, 103 | 'Access-Control-Allow-Methods': {'type': 'string'}, 104 | 'Access-Control-Allow-Headers': {'type': 'string'}, 105 | } 106 | } 107 | }, 108 | 'x-amazon-apigateway-integration': { 109 | 'responses': { 110 | 'default': { 111 | 'statusCode': '200', 112 | 'responseParameters': expected_response_params, 113 | } 114 | }, 115 | 'requestTemplates': { 116 | 'application/json': '{"statusCode": 200}' 117 | }, 118 | 'passthroughBehavior': 'when_no_match', 119 | 'type': 'mock', 120 | }, 121 | } 122 | 123 | 124 | def test_can_add_api_key(sample_app, swagger_gen): 125 | @sample_app.route('/api-key-required', api_key_required=True) 126 | def foo(name): 127 | return {} 128 | doc = swagger_gen.generate_swagger(sample_app) 129 | single_method = doc['paths']['/api-key-required']['get'] 130 | assert 'security' in single_method 131 | assert single_method['security'] == { 132 | 'api_key': [] 133 | } 134 | # Also need to add in the api_key definition in the top level 135 | # security definitions. 136 | assert 'securityDefinitions' in doc 137 | assert 'api_key' in doc['securityDefinitions'] 138 | assert doc['securityDefinitions']['api_key'] == { 139 | 'type': 'apiKey', 140 | 'name': 'x-api-key', 141 | 'in': 'header' 142 | } 143 | 144 | 145 | def test_can_add_authorizers(sample_app, swagger_gen): 146 | @sample_app.route('/api-key-required', 147 | authorizer_name='MyUserPool') 148 | def foo(name): 149 | return {} 150 | 151 | # Doesn't matter if you define the authorizer before 152 | # it's referenced. 153 | sample_app.define_authorizer( 154 | name='MyUserPool', 155 | header='Authorization', 156 | auth_type='cognito_user_pools', 157 | provider_arns=['arn:aws:cog:r:1:userpool/name'] 158 | ) 159 | 160 | doc = swagger_gen.generate_swagger(sample_app) 161 | single_method = doc['paths']['/api-key-required']['get'] 162 | assert single_method.get('security') == [{'MyUserPool': []}] 163 | assert 'securityDefinitions' in doc 164 | assert doc['securityDefinitions'].get('MyUserPool') == { 165 | 'in': 'header', 166 | 'type': 'apiKey', 167 | 'name': 'Authorization', 168 | 'x-amazon-apigateway-authtype': 'cognito_user_pools', 169 | 'x-amazon-apigateway-authorizer': { 170 | 'type': 'cognito_user_pools', 171 | 'providerARNs': ['arn:aws:cog:r:1:userpool/name'] 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /docs/source/topics/sdks.rst: -------------------------------------------------------------------------------- 1 | SDK Generation 2 | ============== 3 | 4 | The ``@app.route(...)`` information you provide chalice allows 5 | it to create corresponding routes in API Gateway. One of the benefits of this 6 | approach is that we can leverage API Gateway's SDK generation process. 7 | Chalice offers a ``chalice generate-sdk`` command that will automatically 8 | generate an SDK based on your declared routes. 9 | 10 | .. note:: 11 | The only supported language at this time is javascript. 12 | 13 | Keep in mind that chalice itself does not have any logic for generating 14 | SDKs. The SDK generation happens service side in `API Gateway`_, the 15 | ``chalice generate-sdk`` is just a high level wrapper around that 16 | functionality. 17 | 18 | To generate an SDK for a chalice app, run this command from the project 19 | directory:: 20 | 21 | $ chalice generate-sdk /tmp/sdk 22 | 23 | You should now have a generated javascript sdk in ``/tmp/sdk``. 24 | API Gateway includes a ``README.md`` as part of its SDK generation 25 | which contains details on how to use the javascript SDK. 26 | 27 | Example 28 | ------- 29 | 30 | Suppose we have the following chalice app: 31 | 32 | .. code-block:: python 33 | 34 | from chalice import Chalice 35 | 36 | app = Chalice(app_name='sdktest') 37 | 38 | @app.route('/', cors=True) 39 | def index(): 40 | return {'hello': 'world'} 41 | 42 | @app.route('/foo', cors=True) 43 | def foo(): 44 | return {'foo': True} 45 | 46 | @app.route('/hello/{name}', cors=True) 47 | def hello_name(name): 48 | return {'hello': name} 49 | 50 | @app.route('/users/{user_id}', methods=['PUT'], cors=True) 51 | def update_user(user_id): 52 | return {"msg": "fake updated user", "userId": user_id} 53 | 54 | 55 | Let's generate a javascript SDK and test it out in the browser. 56 | Run the following command from the project dir:: 57 | 58 | $ chalice generate-sdk /tmp/sdkdemo 59 | $ cd /tmp/sdkdemo 60 | $ ls -la 61 | -rw-r--r-- 1 jamessar r 3227 Nov 21 17:06 README.md 62 | -rw-r--r-- 1 jamessar r 9243 Nov 21 17:06 apigClient.js 63 | drwxr-xr-x 6 jamessar r 204 Nov 21 17:06 lib 64 | 65 | You should now be able to follow the instructions from API Gateway in the 66 | ``README.md`` file. Below is a snippet that shows how the generated 67 | javascript SDK methods correspond to the ``@app.route()`` calls in chalice. 68 | 69 | .. code-block:: html 70 | 71 | 96 | 97 | 98 | 99 | 100 | 101 | Example HTML File 102 | ~~~~~~~~~~~~~~~~~ 103 | 104 | If you want to try out the example above, you can use the following index.html 105 | page to test: 106 | 107 | .. code-block:: html 108 | 109 | 110 | 111 | 112 | SDK Test 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 154 | 155 | 156 |
result of rootGet()
157 |
result of fooGet()
158 |
result of helloNameGet({name: 'jimmy'})
159 |
result of usersUserIdPut({user_id: '123'})
160 | 161 | 162 | 163 | 164 | .. _API Gateway: http://docs.aws.amazon.com/apigateway/latest/developerguide/how-to-generate-sdk.html 165 | -------------------------------------------------------------------------------- /chalice/package.py: -------------------------------------------------------------------------------- 1 | import os 2 | import copy 3 | import json 4 | import hashlib 5 | 6 | from typing import Any, Dict # noqa 7 | 8 | from chalice.deploy.swagger import CFNSwaggerGenerator 9 | from chalice.deploy.swagger import SwaggerGenerator # noqa 10 | from chalice.deploy.packager import LambdaDeploymentPackager 11 | from chalice.deploy.deployer import ApplicationPolicyHandler 12 | from chalice.utils import OSUtils 13 | from chalice.config import Config # noqa 14 | from chalice.app import Chalice # noqa 15 | from chalice.policy import AppPolicyGenerator 16 | 17 | 18 | def create_app_packager(config): 19 | # type: (Config) -> AppPackager 20 | osutils = OSUtils() 21 | # The config object does not handle a default value 22 | # for autogen'ing a policy so we need to handle this here. 23 | return AppPackager( 24 | # We're add place holder values that will be filled in once the 25 | # lambda function is deployed. 26 | SAMTemplateGenerator( 27 | CFNSwaggerGenerator('{region}', '{lambda_arn}'), 28 | PreconfiguredPolicyGenerator( 29 | config, 30 | ApplicationPolicyHandler( 31 | osutils, AppPolicyGenerator(osutils)))), 32 | LambdaDeploymentPackager() 33 | ) 34 | 35 | 36 | class PreconfiguredPolicyGenerator(object): 37 | def __init__(self, config, policy_gen): 38 | # type: (Config, ApplicationPolicyHandler) -> None 39 | self._config = config 40 | self._policy_gen = policy_gen 41 | 42 | def generate_policy_from_app_source(self): 43 | # type: () -> Dict[str, Any] 44 | return self._policy_gen.generate_policy_from_app_source( 45 | self._config) 46 | 47 | 48 | class SAMTemplateGenerator(object): 49 | _BASE_TEMPLATE = { 50 | 'AWSTemplateFormatVersion': '2010-09-09', 51 | 'Transform': 'AWS::Serverless-2016-10-31', 52 | 'Outputs': { 53 | 'RestAPIId': { 54 | 'Value': {'Ref': 'RestAPI'}, 55 | }, 56 | 'APIHandlerName': { 57 | 'Value': {'Ref': 'APIHandler'}, 58 | }, 59 | 'APIHandlerArn': { 60 | 'Value': {'Fn::GetAtt': ['APIHandler', 'Arn']} 61 | }, 62 | 'EndpointURL': { 63 | 'Value': { 64 | 'Fn::Sub': ( 65 | 'https://${RestAPI}.execute-api.${AWS::Region}' 66 | # The api_gateway_stage is filled in when 67 | # the template is built. 68 | '.amazonaws.com/%s/' 69 | ) 70 | } 71 | } 72 | } 73 | } # type: Dict[str, Any] 74 | 75 | def __init__(self, swagger_generator, policy_generator): 76 | # type: (SwaggerGenerator, PreconfiguredPolicyGenerator) -> None 77 | self._swagger_generator = swagger_generator 78 | self._policy_generator = policy_generator 79 | 80 | def generate_sam_template(self, config, code_uri=''): 81 | # type: (Config, str) -> Dict[str, Any] 82 | template = copy.deepcopy(self._BASE_TEMPLATE) 83 | resources = { 84 | 'APIHandler': self._generate_serverless_function(config, code_uri), 85 | 'RestAPI': self._generate_rest_api( 86 | config.chalice_app, config.api_gateway_stage), 87 | } 88 | template['Resources'] = resources 89 | self._update_endpoint_url_output(template, config) 90 | return template 91 | 92 | def _update_endpoint_url_output(self, template, config): 93 | # type: (Dict[str, Any], Config) -> None 94 | url = template['Outputs']['EndpointURL']['Value']['Fn::Sub'] 95 | template['Outputs']['EndpointURL']['Value']['Fn::Sub'] = ( 96 | url % config.api_gateway_stage) 97 | 98 | def _generate_serverless_function(self, config, code_uri): 99 | # type: (Config, str) -> Dict[str, Any] 100 | properties = { 101 | 'Runtime': 'python2.7', 102 | 'Handler': 'app.app', 103 | 'CodeUri': code_uri, 104 | 'Events': self._generate_function_events(config.chalice_app), 105 | 'Policies': [self._generate_iam_policy()], 106 | } 107 | if config.environment_variables: 108 | properties['Environment'] = { 109 | 'Variables': config.environment_variables 110 | } 111 | return { 112 | 'Type': 'AWS::Serverless::Function', 113 | 'Properties': properties, 114 | } 115 | 116 | def _generate_function_events(self, app): 117 | # type: (Chalice) -> Dict[str, Any] 118 | events = {} 119 | for path, view in app.routes.items(): 120 | for http_method in view.methods: 121 | key_name = ''.join([ 122 | view.view_name, http_method.lower(), 123 | hashlib.md5(view.view_name).hexdigest()[:4], 124 | ]) 125 | events[key_name] = { 126 | 'Type': 'Api', 127 | 'Properties': { 128 | 'Path': view.uri_pattern, 129 | 'RestApiId': {'Ref': 'RestAPI'}, 130 | 'Method': http_method.lower(), 131 | } 132 | } 133 | return events 134 | 135 | def _generate_rest_api(self, app, api_gateway_stage): 136 | # type: (Chalice, str) -> Dict[str, Any] 137 | swagger_definition = self._swagger_generator.generate_swagger(app) 138 | properties = { 139 | 'StageName': api_gateway_stage, 140 | 'DefinitionBody': swagger_definition, 141 | } 142 | return { 143 | 'Type': 'AWS::Serverless::Api', 144 | 'Properties': properties, 145 | } 146 | 147 | def _generate_iam_policy(self): 148 | # type: () -> Dict[str, Any] 149 | return self._policy_generator.generate_policy_from_app_source() 150 | 151 | 152 | class AppPackager(object): 153 | def __init__(self, 154 | sam_templater, # type: SAMTemplateGenerator 155 | lambda_packager, # type: LambdaDeploymentPackager 156 | ): 157 | # type: (...) -> None 158 | self._sam_templater = sam_templater 159 | self._lambda_packaager = lambda_packager 160 | 161 | def _to_json(self, doc): 162 | # type: (Any) -> str 163 | return json.dumps(doc, indent=2, separators=(',', ': ')) 164 | 165 | def package_app(self, config, outdir): 166 | # type: (Config, str) -> None 167 | # Deployment package 168 | zip_file = os.path.join(outdir, 'deployment.zip') 169 | self._lambda_packaager.create_deployment_package( 170 | config.project_dir, zip_file) 171 | 172 | # SAM template 173 | sam_template = self._sam_templater.generate_sam_template( 174 | config, './deployment.zip') 175 | if not os.path.isdir(outdir): 176 | os.makedirs(outdir) 177 | with open(os.path.join(outdir, 'sam.json'), 'w') as f: 178 | f.write(self._to_json(sam_template)) 179 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | CHANGELOG 3 | ========= 4 | 5 | 0.7.0 6 | ===== 7 | 8 | Please read the `upgrade notes for 0.7.0 9 | `__ 10 | for more detailed information about upgrading to this release. 11 | 12 | * Add ``chalice package`` command. This will 13 | create a SAM template and Lambda deployment package that 14 | can be subsequently deployed by AWS CloudFormation. 15 | (`#258 `__) 16 | * Add a ``--stage-name`` argument for creating chalice stages. 17 | A chalice stage is a completely separate set of AWS resources. 18 | As a result, most configuration values can also be specified 19 | per chalice stage. 20 | (`#264 __, 21 | `#270 `__) 22 | * Add support for ``iam_role_file``, which allows you to 23 | specify the file location of an IAM policy to use for your app 24 | (`#272 `__) 25 | * Add support for setting environment variables in your app 26 | (`#273 `__) 27 | * Add a ``generate-pipeline`` command 28 | (`#278 `__) 29 | 30 | 31 | 0.6.0 32 | ===== 33 | 34 | Check out the `upgrade notes for 0.6.0 35 | `__ 36 | for more detailed information about changes in this release. 37 | 38 | * Add port parameter to local command 39 | (`#220 `__) 40 | * Add support for binary vendored packages 41 | (`#182 `__, 42 | `#106 `__, 43 | `#42 `__) 44 | * Add support for customizing the returned HTTP response 45 | (`#240 `__, 46 | `#218 `__, 47 | `#110 `__, 48 | `#30 `__, 49 | `#226 `__) 50 | * Always inject latest runtime to allow for chalice upgrades 51 | (`#245 `__) 52 | 53 | 54 | 0.5.1 55 | ===== 56 | 57 | * Add support for serializing decimals in ``chalice local`` 58 | (`#187 `__) 59 | * Add stdout handler for root logger when using ``chalice local`` 60 | (`#186 `__) 61 | * Map query string parameters when using ``chalice local`` 62 | (`#184 `__) 63 | * Support Content-Type with a charset 64 | (`#180 `__) 65 | * Fix not all resources being retrieved due to pagination 66 | (`#188 `__) 67 | * Fix issue where root resource was not being correctly retrieved 68 | (`#205 `__) 69 | * Handle case where local policy does not exist 70 | (`29 `__) 71 | 72 | 73 | 0.5.0 74 | ===== 75 | 76 | * Add default application logger 77 | (`#149 `__) 78 | * Return 405 when method is not supported when running 79 | ``chalice local`` 80 | (`#159 `__) 81 | * Add path params as requestParameters so they can be used 82 | in generated SDKs as well as cache keys 83 | (`#163 `__) 84 | * Map cognito user pool claims as part of request context 85 | (`#165 `__) 86 | * Add ``chalice url`` command to print the deployed URL 87 | (`#169 `__) 88 | * Bump up retry limit on initial function creation to 30 seconds 89 | (`#172 `__) 90 | * Add support for ``DELETE`` and ``PATCH`` in ``chalice local`` 91 | (`#167 `__) 92 | * Add ``chalice generate-sdk`` command 93 | (`#178 `__) 94 | 95 | 96 | 0.4.0 97 | ===== 98 | 99 | * Fix issue where role name to arn lookup was failing due to lack of pagination 100 | (`#139 `__) 101 | * Raise errors when unknown kwargs are provided to ``app.route(...)`` 102 | (`#144 `__) 103 | * Raise validation error when configuring CORS and an OPTIONS method 104 | (`#142 `__) 105 | * Add support for multi-file applications 106 | (`#21 `__) 107 | * Add support for ``chalice local``, which runs a local HTTP server for testing 108 | (`#22 `__) 109 | 110 | 111 | 0.3.0 112 | ===== 113 | 114 | * Fix bug with case insensitive headers 115 | (`#129 `__) 116 | * Add initial support for CORS 117 | (`#133 `__) 118 | * Only add API gateway permissions if needed 119 | (`#48 `__) 120 | * Fix error when dict comprehension is encountered during policy generation 121 | (`#131 `__) 122 | * Add ``--version`` and ``--debug`` options to the chalice CLI 123 | 124 | 125 | 0.2.0 126 | ===== 127 | 128 | * Add support for input content types besides ``application/json`` 129 | (`#96 `__) 130 | * Allow ``ChaliceViewErrors`` to propagate, so that API Gateway 131 | can properly map HTTP status codes in non debug mode 132 | (`#113 `__) 133 | * Add windows compatibility 134 | (`#31 `__, 135 | `#124 `__, 136 | `#103 `__) 137 | 138 | 139 | 0.1.0 140 | ===== 141 | 142 | * Require ``virtualenv`` as a package dependency. 143 | (`#33 `__) 144 | * Add ``--profile`` option when creating a new project 145 | (`#28 `__) 146 | * Add support for more error codes exceptions 147 | (`#34 `__) 148 | * Improve error validation when routes containing a 149 | trailing ``/`` char 150 | (`#65 `__) 151 | * Validate duplicate route entries 152 | (`#79 `__) 153 | * Ignore lambda expressions in policy analyzer 154 | (`#74 `__) 155 | * Print original error traceback in debug mode 156 | (`#50 `__) 157 | * Add support for authenticate routes 158 | (`#14 `__) 159 | * Add ability to disable IAM role management 160 | (`#61 `__) 161 | -------------------------------------------------------------------------------- /docs/source/topics/configfile.rst: -------------------------------------------------------------------------------- 1 | Configuration File 2 | ================== 3 | 4 | Whenever you create a new project using 5 | ``chalice new-project``, a ``.chalice`` directory is created 6 | for you. In this directory is a ``config.json`` file that 7 | you can use to control what happens when you ``chalice deploy``:: 8 | 9 | 10 | $ tree -a 11 | . 12 | ├── .chalice 13 | │   └── config.json 14 | ├── app.py 15 | └── requirements.txt 16 | 17 | 1 directory, 3 files 18 | 19 | 20 | Stage Specific Configuration 21 | ---------------------------- 22 | 23 | As of version 0.7.0 of chalice, you can specify configuration 24 | that is specific to a chalice stage as well as configuration that should 25 | be shared across all stages. See the :doc:`topics/stages` doc for more 26 | information about chalice stages. 27 | 28 | * ``stages`` - This value of this key is a mapping of chalice stage 29 | name to stage configuration. Chalice assumes a default stage name 30 | of ``dev``. If you run the ``chalice new-project`` command on 31 | chalice 0.7.0 or higher, this key along with the default ``dev`` 32 | key will automatically be created for you. See the examples 33 | section below for some stage specific configurations. 34 | 35 | The following config values can either be specified per stage config 36 | or as a top level key which is not tied to a specific key. Whenever 37 | a stage specific configuration value is needed, the ``stages`` mapping 38 | is checked first. If no value is found then the top level keys will 39 | be checked. 40 | 41 | 42 | * ``api_gateway_stage`` - The name of the API gateway stage. This 43 | will also be the URL prefix for your API 44 | (``https://endpoint/prefix/your-api``). 45 | 46 | * ``manage_iam_role`` - ``true``/``false``. Indicates if you 47 | want chalice to create and update the IAM role 48 | used for your application. By default, this value is ``true``. 49 | However, if you have a pre-existing role you've created, you 50 | can set this value to ``false`` and a role will not be created 51 | or updated. 52 | ``"manage_iam_role": false`` means that you are responsible for 53 | managing the role and any associated policies associated with 54 | that role. If this value is ``false`` you must specify 55 | an ``iam_role_arn``, otherwise an error is raised when you 56 | try to run ``chalice deploy``. 57 | 58 | * ``iam_role_arn`` - If ``manage_iam_role`` is ``false``, you 59 | must specify this value that indicates which IAM role arn to 60 | use when configuration your application. This value is only 61 | used if ``manage_iam_role`` is ``false``. 62 | 63 | * ``autogen_policy`` - A boolean value that indicates if chalice 64 | should try to automatically generate an IAM policy based on 65 | analyzing your application source code. The default value is 66 | ``true``. If this value is ``false`` then chalice will load 67 | try to a local file in ``.chalice/policy-.json`` 68 | instead of auto-generating a policy from source code analysis. 69 | 70 | * ``iam_role_file`` - When ``autogen_policy`` is false, chalice 71 | will try to load an IAM policy from disk instead of auto-generating 72 | one based on source code analysis. The default location of this 73 | file is ``.chalice/policy-.json``, e.g 74 | ``.chalice/policy-dev.json``, ``.chalice/policy-prod.json``, etc. 75 | You can change the filename by providing this ``iam_role_file`` 76 | config option. This filename is relative to the ``.chalice`` 77 | directory. 78 | 79 | * ``environment_variables`` - A mapping of key value pairs. These 80 | key value pairs will be set as environment variables in your 81 | deployed application. All environment variables must be strings. 82 | If this key is specified in both a stage specific config option 83 | as well as a top level key, the stage specific environment 84 | variables will be merged into the top level keys. See the 85 | examples section below for a concrete example. 86 | 87 | 88 | Examples 89 | -------- 90 | 91 | Here's an example for configuring IAM policies across stages:: 92 | 93 | { 94 | "version": "2.0", 95 | "app_name": "app", 96 | "stages": { 97 | "dev": { 98 | "autogen_policy": true, 99 | "api_gateway_stage": "dev" 100 | }, 101 | "beta": { 102 | "autogen_policy": false, 103 | "iam_role_file": "beta-app-policy.json" 104 | }, 105 | "prod": { 106 | "manage_iam_role": false, 107 | "iam_role_arn": "arn:aws:iam::...:role/prod-role" 108 | } 109 | } 110 | } 111 | 112 | In this config file we're specifying three stages, ``dev``, ``beta``, 113 | and ``prod``. In the ``dev`` stage, chalice will automatically 114 | generate an IAM policy based on analyzing the application source code. 115 | For the ``beta`` stage, chalice will load the 116 | ``.chalice/beta-app-policy.json`` file and use it as the policy to 117 | associate with the IAM role for that stage. In the ``prod`` stage, 118 | chalice won't modify any IAM roles. It will just set the IAM role 119 | for the Lambda function to be ``arn:aws:iam::...:role/prod-role``. 120 | 121 | Here's an example that show config precedence:: 122 | 123 | 124 | { 125 | "version": "2.0", 126 | "app_name": "app", 127 | "api_gateway_stage": "api" 128 | "stages": { 129 | "dev": { 130 | }, 131 | "beta": { 132 | }, 133 | "prod": { 134 | "api_gateway_stage": "prod", 135 | "manage_iam_role": false, 136 | "iam_role_arn": "arn:aws:iam::...:role/prod-role" 137 | } 138 | } 139 | } 140 | 141 | In this config file, both the ``dev`` and ``beta`` stage will 142 | have an API gateway stage name of ``api`` because they will 143 | default to the top level ``api_gateway_stage`` key. 144 | However, the ``prod`` stage will have an API gateway stage 145 | name of ``prod`` because the ``api_gateway_stage`` is specified 146 | in ``{"stages": {"prod": ...}}`` mapping. 147 | 148 | 149 | In the following example, environment variables are specified 150 | both as top level keys as well as per stage. This allows us to 151 | provide environment variables that all stages should have as well 152 | as stage specific environment variables:: 153 | 154 | 155 | { 156 | "version": "2.0", 157 | "app_name": "app", 158 | "environment_variables": { 159 | "SHARED_CONFIG": "foo" 160 | "OTHER_CONFIG": "from-top" 161 | } 162 | "stages": { 163 | "dev": { 164 | "environment_variables": { 165 | "TABLE_NAME": "dev-table", 166 | "OTHER_CONFIG": "dev-value" 167 | } 168 | }, 169 | "prod": { 170 | "environment_variables": { 171 | "TABLE_NAME": "prod-table", 172 | "OTHER_CONFIG": "prod-value" 173 | } 174 | } 175 | } 176 | } 177 | 178 | For the above config, the ``dev`` stage will have the 179 | following environment variables set:: 180 | 181 | { 182 | "SHARED_CONFIG": "foo", 183 | "TABLE_NAME": "dev-table", 184 | "OTHER_CONFIG": "dev-value", 185 | } 186 | 187 | The ``prod`` stage will have these environment variables set:: 188 | 189 | { 190 | "SHARED_CONFIG": "foo", 191 | "TABLE_NAME": "prod-table", 192 | "OTHER_CONFIG": "prod-value", 193 | } 194 | -------------------------------------------------------------------------------- /tests/integration/test_features.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import shutil 4 | 5 | import botocore.session 6 | import pytest 7 | import requests 8 | 9 | from chalice.cli import load_chalice_app 10 | from chalice.config import Config 11 | from chalice.deploy import deployer 12 | 13 | CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) 14 | PROJECT_DIR = os.path.join(CURRENT_DIR, 'testapp') 15 | CHALICE_DIR = os.path.join(PROJECT_DIR, '.chalice') 16 | 17 | 18 | class SmokeTestApplication(object): 19 | def __init__(self, url, rest_api_id, region_name, name): 20 | if url.endswith('/'): 21 | url = url[:-1] 22 | self.url = url 23 | self.rest_api_id = rest_api_id 24 | self.region_name = region_name 25 | self.name = name 26 | 27 | def get_json(self, url): 28 | if not url.startswith('/'): 29 | url = '/' + url 30 | response = requests.get(self.url + url) 31 | response.raise_for_status() 32 | return response.json() 33 | 34 | 35 | @pytest.fixture(scope='module') 36 | def smoke_test_app(): 37 | application = _deploy_app() 38 | yield application 39 | _delete_app(application) 40 | 41 | 42 | def _deploy_app(): 43 | if not os.path.isdir(CHALICE_DIR): 44 | os.makedirs(CHALICE_DIR) 45 | session = botocore.session.get_session() 46 | config = Config.create( 47 | project_dir=PROJECT_DIR, 48 | app_name='smoketestapp', 49 | stage_name='dev', 50 | autogen_policy=True, 51 | chalice_app=load_chalice_app(PROJECT_DIR), 52 | ) 53 | d = deployer.create_default_deployer(session=session) 54 | rest_api_id, region_name, stage = d.deploy(config) 55 | url = ( 56 | "https://{api_id}.execute-api.{region}.amazonaws.com/{stage}/".format( 57 | api_id=rest_api_id, region=region_name, stage=stage)) 58 | application = SmokeTestApplication(url, rest_api_id, region_name, 'smoketestapp') 59 | return application 60 | 61 | 62 | def _delete_app(application): 63 | s = botocore.session.get_session() 64 | lambda_client = s.create_client('lambda') 65 | lambda_client.delete_function(FunctionName=application.name) 66 | 67 | iam = s.create_client('iam') 68 | policies = iam.list_role_policies(RoleName=application.name) 69 | for name in policies['PolicyNames']: 70 | iam.delete_role_policy(RoleName=application.name, PolicyName=name) 71 | iam.delete_role(RoleName=application.name) 72 | 73 | apig = s.create_client('apigateway') 74 | apig.delete_rest_api(restApiId=application.rest_api_id) 75 | chalice_dir = os.path.join(PROJECT_DIR, '.chalice') 76 | shutil.rmtree(chalice_dir) 77 | os.makedirs(chalice_dir) 78 | 79 | 80 | def test_returns_simple_response(smoke_test_app): 81 | assert smoke_test_app.get_json('/') == {'hello': 'world'} 82 | 83 | 84 | def test_can_have_nested_routes(smoke_test_app): 85 | assert smoke_test_app.get_json('/a/b/c/d/e/f/g') == {'nested': True} 86 | 87 | 88 | def test_supports_path_params(smoke_test_app): 89 | assert smoke_test_app.get_json('/path/foo') == {'path': 'foo'} 90 | assert smoke_test_app.get_json('/path/bar') == {'path': 'bar'} 91 | 92 | 93 | def test_supports_post(smoke_test_app): 94 | app_url = smoke_test_app.url 95 | response = requests.post(app_url + '/post') 96 | response.raise_for_status() 97 | assert response.json() == {'success': True} 98 | with pytest.raises(requests.HTTPError): 99 | # Only POST is supported. 100 | response = requests.get(app_url + '/post') 101 | response.raise_for_status() 102 | 103 | 104 | def test_supports_put(smoke_test_app): 105 | app_url = smoke_test_app.url 106 | response = requests.put(app_url + '/put') 107 | response.raise_for_status() 108 | assert response.json() == {'success': True} 109 | with pytest.raises(requests.HTTPError): 110 | # Only PUT is supported. 111 | response = requests.get(app_url + '/put') 112 | response.raise_for_status() 113 | 114 | 115 | def test_can_read_json_body_on_post(smoke_test_app): 116 | app_url = smoke_test_app.url 117 | response = requests.post( 118 | app_url + '/jsonpost', data=json.dumps({'hello': 'world'}), 119 | headers={'Content-Type': 'application/json'}) 120 | response.raise_for_status() 121 | assert response.json() == {'json_body': {'hello': 'world'}} 122 | 123 | 124 | def test_can_raise_bad_request(smoke_test_app): 125 | response = requests.get(smoke_test_app.url + '/badrequest') 126 | assert response.status_code == 400 127 | assert response.json()['Code'] == 'BadRequestError' 128 | assert response.json()['Message'] == 'BadRequestError: Bad request.' 129 | 130 | 131 | def test_can_raise_not_found(smoke_test_app): 132 | response = requests.get(smoke_test_app.url + '/notfound') 133 | assert response.status_code == 404 134 | assert response.json()['Code'] == 'NotFoundError' 135 | 136 | 137 | def test_unexpected_error_raises_500_in_prod_mode(smoke_test_app): 138 | response = requests.get(smoke_test_app.url + '/arbitrary-error') 139 | assert response.status_code == 500 140 | assert response.json()['Code'] == 'InternalServerError' 141 | assert 'internal server error' in response.json()['Message'] 142 | 143 | 144 | def test_can_route_multiple_methods_in_one_view(smoke_test_app): 145 | response = requests.get(smoke_test_app.url + '/multimethod') 146 | response.raise_for_status() 147 | assert response.json()['method'] == 'GET' 148 | 149 | response = requests.post(smoke_test_app.url + '/multimethod') 150 | response.raise_for_status() 151 | assert response.json()['method'] == 'POST' 152 | 153 | 154 | def test_form_encoded_content_type(smoke_test_app): 155 | response = requests.post(smoke_test_app.url + '/formencoded', 156 | data={'foo': 'bar'}) 157 | response.raise_for_status() 158 | assert response.json() == {'parsed': {'foo': ['bar']}} 159 | 160 | 161 | def test_can_support_cors(smoke_test_app): 162 | response = requests.get(smoke_test_app.url + '/cors') 163 | response.raise_for_status() 164 | assert response.headers['Access-Control-Allow-Origin'] == '*' 165 | 166 | # Should also have injected an OPTIONs request. 167 | response = requests.options(smoke_test_app.url + '/cors') 168 | response.raise_for_status() 169 | headers = response.headers 170 | assert headers['Access-Control-Allow-Origin'] == '*' 171 | assert headers['Access-Control-Allow-Headers'] == ( 172 | 'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token') 173 | assert headers['Access-Control-Allow-Methods'] == 'GET,POST,PUT,OPTIONS' 174 | 175 | 176 | def test_to_dict_is_also_json_serializable(smoke_test_app): 177 | assert 'headers' in smoke_test_app.get_json('/todict') 178 | 179 | 180 | def test_multfile_support(smoke_test_app): 181 | response = smoke_test_app.get_json('/multifile') 182 | assert response == {'message': 'success'} 183 | 184 | 185 | def test_custom_response(smoke_test_app): 186 | url = smoke_test_app.url + '/custom-response' 187 | response = requests.get(url) 188 | response.raise_for_status() 189 | # Custom header 190 | assert response.headers['Content-Type'] == 'text/plain' 191 | # Custom status code 192 | assert response.status_code == 204 193 | -------------------------------------------------------------------------------- /chalice/deploy/swagger.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | from typing import Any, List, Dict # noqa 4 | 5 | from chalice.app import Chalice, RouteEntry # noqa 6 | 7 | 8 | class SwaggerGenerator(object): 9 | 10 | _BASE_TEMPLATE = { 11 | 'swagger': '2.0', 12 | 'info': { 13 | 'version': '1.0', 14 | 'title': '' 15 | }, 16 | 'schemes': ['https'], 17 | 'paths': {}, 18 | 'definitions': { 19 | 'Empty': { 20 | 'type': 'object', 21 | 'title': 'Empty Schema', 22 | } 23 | } 24 | } # type: Dict[str, Any] 25 | 26 | def __init__(self, region, lambda_arn): 27 | # type: (str, str) -> None 28 | self._region = region 29 | self._lambda_arn = lambda_arn 30 | 31 | def generate_swagger(self, app): 32 | # type: (Chalice) -> Dict[str, Any] 33 | api = copy.deepcopy(self._BASE_TEMPLATE) 34 | api['info']['title'] = app.app_name 35 | self._add_route_paths(api, app) 36 | return api 37 | 38 | def _add_route_paths(self, api, app): 39 | # type: (Dict[str, Any], Chalice) -> None 40 | for path, view in app.routes.items(): 41 | swagger_for_path = {} # type: Dict[str, Any] 42 | api['paths'][path] = swagger_for_path 43 | for http_method in view.methods: 44 | current = self._generate_route_method(view) 45 | if 'security' in current: 46 | self._add_to_security_definition( 47 | current['security'], api, app.authorizers) 48 | swagger_for_path[http_method.lower()] = current 49 | if view.cors: 50 | self._add_preflight_request(view, swagger_for_path) 51 | 52 | def _add_to_security_definition(self, security, api_config, authorizers): 53 | # type: (Any, Dict[str, Any], Dict[str, Any]) -> None 54 | if 'api_key' in security: 55 | # This is just the api_key_required=True config 56 | swagger_snippet = { 57 | 'type': 'apiKey', 58 | 'name': 'x-api-key', 59 | 'in': 'header', 60 | } # type: Dict[str, Any] 61 | api_config.setdefault( 62 | 'securityDefinitions', {})['api_key'] = swagger_snippet 63 | elif isinstance(security, list): 64 | for auth in security: 65 | # TODO: Add validation checks for unknown auth references. 66 | name = auth.keys()[0] 67 | authorizer_config = authorizers[name] 68 | auth_type = authorizer_config['auth_type'] 69 | swagger_snippet = { 70 | 'in': 'header', 71 | 'type': 'apiKey', 72 | 'name': authorizer_config['header'], 73 | 'x-amazon-apigateway-authtype': auth_type, 74 | 'x-amazon-apigateway-authorizer': { 75 | 'type': auth_type, 76 | 'providerARNs': authorizer_config['provider_arns'], 77 | } 78 | } 79 | api_config.setdefault( 80 | 'securityDefinitions', {})[name] = swagger_snippet 81 | 82 | def _generate_route_method(self, view): 83 | # type: (RouteEntry) -> Dict[str, Any] 84 | current = { 85 | 'consumes': view.content_types, 86 | 'produces': ['application/json'], 87 | 'responses': self._generate_precanned_responses(), 88 | 'x-amazon-apigateway-integration': self._generate_apig_integ( 89 | view), 90 | } # type: Dict[str, Any] 91 | if view.api_key_required: 92 | # When this happens we also have to add the relevant portions 93 | # to the security definitions. We have to someone indicate 94 | # this because this neeeds to be added to the global config 95 | # file. 96 | current['security'] = {'api_key': []} 97 | if view.authorizer_name: 98 | current['security'] = [{view.authorizer_name: []}] 99 | return current 100 | 101 | def _generate_precanned_responses(self): 102 | # type: () -> Dict[str, Any] 103 | responses = { 104 | '200': { 105 | 'description': '200 response', 106 | 'schema': { 107 | '$ref': '#/definitions/Empty', 108 | } 109 | } 110 | } 111 | return responses 112 | 113 | def _uri(self): 114 | # type: () -> Any 115 | return ('arn:aws:apigateway:{region}:lambda:path/2015-03-31' 116 | '/functions/{lambda_arn}/invocations').format( 117 | region=self._region, lambda_arn=self._lambda_arn) 118 | 119 | def _generate_apig_integ(self, view): 120 | # type: (RouteEntry) -> Dict[str, Any] 121 | apig_integ = { 122 | 'responses': { 123 | 'default': { 124 | 'statusCode': "200", 125 | } 126 | }, 127 | 'uri': self._uri(), 128 | 'passthroughBehavior': 'when_no_match', 129 | 'httpMethod': 'POST', 130 | 'contentHandling': 'CONVERT_TO_TEXT', 131 | 'type': 'aws_proxy', 132 | } 133 | if view.view_args: 134 | self._add_view_args(apig_integ, view.view_args) 135 | return apig_integ 136 | 137 | def _add_view_args(self, apig_integ, view_args): 138 | # type: (Dict[str, Any], List[str]) -> None 139 | apig_integ['parameters'] = [ 140 | {'name': name, 'in': 'path', 'required': True, 'type': 'string'} 141 | for name in view_args 142 | ] 143 | 144 | def _add_preflight_request(self, view, swagger_for_path): 145 | # type: (RouteEntry, Dict[str, Any]) -> None 146 | methods = view.methods + ['OPTIONS'] 147 | allowed_methods = ','.join(methods) 148 | response_params = { 149 | "method.response.header.Access-Control-Allow-Methods": ( 150 | "'%s'" % allowed_methods), 151 | "method.response.header.Access-Control-Allow-Headers": ( 152 | "'Content-Type,X-Amz-Date,Authorization,X-Api-Key," 153 | "X-Amz-Security-Token'"), 154 | "method.response.header.Access-Control-Allow-Origin": "'*'" 155 | } 156 | 157 | options_request = { 158 | "consumes": ["application/json"], 159 | "produces": ["application/json"], 160 | "responses": { 161 | "200": { 162 | "description": "200 response", 163 | "schema": {"$ref": "#/definitions/Empty"}, 164 | "headers": { 165 | "Access-Control-Allow-Origin": {"type": "string"}, 166 | "Access-Control-Allow-Methods": {"type": "string"}, 167 | "Access-Control-Allow-Headers": {"type": "string"}, 168 | } 169 | } 170 | }, 171 | "x-amazon-apigateway-integration": { 172 | "responses": { 173 | "default": { 174 | "statusCode": "200", 175 | "responseParameters": response_params, 176 | } 177 | }, 178 | "requestTemplates": { 179 | "application/json": "{\"statusCode\": 200}" 180 | }, 181 | "passthroughBehavior": "when_no_match", 182 | "type": "mock" 183 | } 184 | } 185 | swagger_for_path['options'] = options_request 186 | 187 | 188 | class CFNSwaggerGenerator(SwaggerGenerator): 189 | def _uri(self): 190 | # type: () -> Any 191 | # TODO: Does this have to be return type Any? 192 | return { 193 | 'Fn::Sub': ( 194 | 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31' 195 | '/functions/${APIHandler.Arn}/invocations' 196 | ) 197 | } 198 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Chalice documentation build configuration file, created by 4 | # sphinx-quickstart on Tue May 17 14:09:17 2016. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys 16 | import os 17 | 18 | import guzzle_sphinx_theme 19 | 20 | _ROOT_SOURCE = os.path.dirname(os.path.abspath(__file__)) 21 | sys.path.insert(0, _ROOT_SOURCE) 22 | 23 | # If extensions (or modules to document with autodoc) are in another directory, 24 | # add these directories to sys.path here. If the directory is relative to the 25 | # documentation root, use os.path.abspath to make it absolute, like shown here. 26 | #sys.path.insert(0, os.path.abspath('.')) 27 | 28 | # -- General configuration ------------------------------------------------ 29 | 30 | # If your documentation needs a minimal Sphinx version, state it here. 31 | #needs_sphinx = '1.0' 32 | 33 | # Add any Sphinx extension module names here, as strings. They can be 34 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 35 | # ones. 36 | extensions = [ 37 | 'sphinx.ext.autodoc', 38 | 'sphinx.ext.viewcode', 39 | 'chalicedocs', 40 | ] 41 | 42 | # Add any paths that contain templates here, relative to this directory. 43 | templates_path = ['_templates'] 44 | 45 | # The suffix(es) of source filenames. 46 | # You can specify multiple suffix as a list of string: 47 | # source_suffix = ['.rst', '.md'] 48 | source_suffix = '.rst' 49 | 50 | # The encoding of source files. 51 | #source_encoding = 'utf-8-sig' 52 | 53 | # The master toctree document. 54 | master_doc = 'index' 55 | 56 | # General information about the project. 57 | project = u'Python Serverless Microframework for AWS' 58 | copyright = u'2016, James Saryerwinnie' 59 | author = u'James Saryerwinnie' 60 | 61 | # The version info for the project you're documenting, acts as replacement for 62 | # |version| and |release|, also used in various other places throughout the 63 | # built documents. 64 | # 65 | # The short X.Y version. 66 | version = u'0.7.0' 67 | # The full version, including alpha/beta/rc tags. 68 | release = u'0.7.0' 69 | 70 | # The language for content autogenerated by Sphinx. Refer to documentation 71 | # for a list of supported languages. 72 | # 73 | # This is also used if you do content translation via gettext catalogs. 74 | # Usually you set "language" from the command line for these cases. 75 | language = None 76 | 77 | # There are two options for replacing |today|: either, you set today to some 78 | # non-false value, then it is used: 79 | #today = '' 80 | # Else, today_fmt is used as the format for a strftime call. 81 | #today_fmt = '%B %d, %Y' 82 | 83 | # List of patterns, relative to source directory, that match files and 84 | # directories to ignore when looking for source files. 85 | # This patterns also effect to html_static_path and html_extra_path 86 | exclude_patterns = [] 87 | 88 | # The reST default role (used for this markup: `text`) to use for all 89 | # documents. 90 | #default_role = None 91 | 92 | # If true, '()' will be appended to :func: etc. cross-reference text. 93 | #add_function_parentheses = True 94 | 95 | # If true, the current module name will be prepended to all description 96 | # unit titles (such as .. function::). 97 | #add_module_names = True 98 | 99 | # If true, sectionauthor and moduleauthor directives will be shown in the 100 | # output. They are ignored by default. 101 | #show_authors = False 102 | 103 | # The name of the Pygments (syntax highlighting) style to use. 104 | pygments_style = 'sphinx' 105 | 106 | # A list of ignored prefixes for module index sorting. 107 | #modindex_common_prefix = [] 108 | 109 | # If true, keep warnings as "system message" paragraphs in the built documents. 110 | #keep_warnings = False 111 | 112 | # If true, `todo` and `todoList` produce output, else they produce nothing. 113 | todo_include_todos = False 114 | 115 | 116 | # -- Options for HTML output ---------------------------------------------- 117 | 118 | # The theme to use for HTML and HTML Help pages. See the documentation for 119 | # a list of builtin themes. 120 | extensions.append("guzzle_sphinx_theme") 121 | html_translator_class = 'guzzle_sphinx_theme.HTMLTranslator' 122 | html_theme_path = guzzle_sphinx_theme.html_theme_path() 123 | html_theme = 'guzzle_sphinx_theme' 124 | # Guzzle theme options (see theme.conf for more information) 125 | 126 | html_theme_options = {} 127 | 128 | 129 | # The name for this set of Sphinx documents. 130 | # " v documentation" by default. 131 | 132 | # A shorter title for the navigation bar. Default is the same as html_title. 133 | html_short_title = 'User Documentation' 134 | 135 | # The name of an image file (relative to this directory) to place at the top 136 | # of the sidebar. 137 | #html_logo = None 138 | 139 | # The name of an image file (relative to this directory) to use as a favicon of 140 | # the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 141 | # pixels large. 142 | #html_favicon = None 143 | 144 | # Add any paths that contain custom static files (such as style sheets) here, 145 | # relative to this directory. They are copied after the builtin static files, 146 | # so a file named "default.css" will overwrite the builtin "default.css". 147 | html_static_path = ['_static'] 148 | 149 | # Add any extra paths that contain custom files (such as robots.txt or 150 | # .htaccess) here, relative to this directory. These files are copied 151 | # directly to the root of the documentation. 152 | #html_extra_path = [] 153 | 154 | # If not None, a 'Last updated on:' timestamp is inserted at every page 155 | # bottom, using the given strftime format. 156 | # The empty string is equivalent to '%b %d, %Y'. 157 | #html_last_updated_fmt = None 158 | 159 | # If true, SmartyPants will be used to convert quotes and dashes to 160 | # typographically correct entities. 161 | #html_use_smartypants = True 162 | 163 | # Custom sidebar templates, maps document names to template names. 164 | #html_sidebars = {} 165 | 166 | # Additional templates that should be rendered to pages, maps page names to 167 | # template names. 168 | #html_additional_pages = {} 169 | 170 | # If false, no module index is generated. 171 | #html_domain_indices = True 172 | 173 | # If false, no index is generated. 174 | #html_use_index = True 175 | 176 | # If true, the index is split into individual pages for each letter. 177 | #html_split_index = False 178 | 179 | # If true, links to the reST sources are added to the pages. 180 | #html_show_sourcelink = True 181 | 182 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 183 | #html_show_sphinx = True 184 | 185 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 186 | #html_show_copyright = True 187 | 188 | # If true, an OpenSearch description file will be output, and all pages will 189 | # contain a tag referring to it. The value of this option must be the 190 | # base URL from which the finished HTML is served. 191 | #html_use_opensearch = '' 192 | 193 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 194 | #html_file_suffix = None 195 | 196 | # Language to be used for generating the HTML full-text search index. 197 | # Sphinx supports the following languages: 198 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' 199 | # 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr', 'zh' 200 | #html_search_language = 'en' 201 | 202 | # A dictionary with options for the search language support, empty by default. 203 | # 'ja' uses this config value. 204 | # 'zh' user can custom change `jieba` dictionary path. 205 | #html_search_options = {'type': 'default'} 206 | 207 | # The name of a javascript file (relative to the configuration directory) that 208 | # implements a search results scorer. If empty, the default will be used. 209 | #html_search_scorer = 'scorer.js' 210 | 211 | # Output file base name for HTML help builder. 212 | htmlhelp_basename = 'Chalicedoc' 213 | 214 | 215 | primary_domain = 'py' 216 | -------------------------------------------------------------------------------- /chalice/local.py: -------------------------------------------------------------------------------- 1 | """Dev server used for running a chalice app locally. 2 | 3 | This is intended only for local development purposes. 4 | 5 | """ 6 | import functools 7 | from collections import namedtuple 8 | from BaseHTTPServer import HTTPServer 9 | from BaseHTTPServer import BaseHTTPRequestHandler 10 | 11 | 12 | from chalice.app import Chalice # noqa 13 | from typing import List, Any, Dict, Tuple, Callable # noqa 14 | 15 | try: 16 | from urllib.parse import urlparse, parse_qs 17 | except ImportError: 18 | from urlparse import urlparse, parse_qs 19 | 20 | 21 | MatchResult = namedtuple('MatchResult', ['route', 'captured', 'query_params']) 22 | EventType = Dict[str, Any] 23 | HandlerCls = Callable[..., 'ChaliceRequestHandler'] 24 | ServerCls = Callable[..., 'HTTPServer'] 25 | 26 | 27 | def create_local_server(app_obj, port): 28 | # type: (Chalice, int) -> LocalDevServer 29 | return LocalDevServer(app_obj, port) 30 | 31 | 32 | class RouteMatcher(object): 33 | def __init__(self, route_urls): 34 | # type: (List[str]) -> None 35 | # Sorting the route_urls ensures we always check 36 | # the concrete routes for a prefix before the 37 | # variable/capture parts of the route, e.g 38 | # '/foo/bar' before '/foo/{capture}' 39 | self.route_urls = sorted(route_urls) 40 | 41 | def match_route(self, url): 42 | # type: (str) -> MatchResult 43 | """Match the url against known routes. 44 | 45 | This method takes a concrete route "/foo/bar", and 46 | matches it against a set of routes. These routes can 47 | use param substitution corresponding to API gateway patterns. 48 | For example:: 49 | 50 | match_route('/foo/bar') -> '/foo/{name}' 51 | 52 | """ 53 | # Otherwise we need to check for param substitution 54 | parsed_url = urlparse(url) 55 | query_params = {k: v[0] for k, v in parse_qs(parsed_url.query).items()} 56 | parts = parsed_url.path.split('/') 57 | captured = {} 58 | for route_url in self.route_urls: 59 | url_parts = route_url.split('/') 60 | if len(parts) == len(url_parts): 61 | for i, j in zip(parts, url_parts): 62 | if j.startswith('{') and j.endswith('}'): 63 | captured[j[1:-1]] = i 64 | continue 65 | if i != j: 66 | break 67 | else: 68 | return MatchResult(route_url, captured, query_params) 69 | raise ValueError("No matching route found for: %s" % url) 70 | 71 | 72 | class LambdaEventConverter(object): 73 | """Convert an HTTP request to an event dict used by lambda.""" 74 | def __init__(self, route_matcher): 75 | # type: (RouteMatcher) -> None 76 | self._route_matcher = route_matcher 77 | 78 | def create_lambda_event(self, method, path, headers, body=None): 79 | # type: (str, str, Dict[str, str], str) -> EventType 80 | view_route = self._route_matcher.match_route(path) 81 | if body is None: 82 | body = '{}' 83 | return { 84 | 'requestContext': { 85 | 'httpMethod': method, 86 | 'resourcePath': view_route.route, 87 | }, 88 | 'headers': dict(headers), 89 | 'queryStringParameters': view_route.query_params, 90 | 'body': body, 91 | 'pathParameters': view_route.captured, 92 | 'stageVariables': {}, 93 | } 94 | 95 | 96 | class ChaliceRequestHandler(BaseHTTPRequestHandler): 97 | 98 | protocol = 'HTTP/1.1' 99 | 100 | def __init__(self, request, client_address, server, app_object): 101 | # type: (bytes, Tuple[str, int], HTTPServer, Chalice) -> None 102 | self.app_object = app_object 103 | self.event_converter = LambdaEventConverter( 104 | RouteMatcher(list(app_object.routes))) 105 | BaseHTTPRequestHandler.__init__( 106 | self, request, client_address, server) # type: ignore 107 | 108 | def _generic_handle(self): 109 | # type: () -> None 110 | lambda_event = self._generate_lambda_event() 111 | self._do_invoke_view_function(lambda_event) 112 | 113 | def _do_invoke_view_function(self, lambda_event): 114 | # type: (EventType) -> None 115 | lambda_context = None 116 | response = self.app_object(lambda_event, lambda_context) 117 | self._send_http_response(lambda_event, response) 118 | 119 | def _send_http_response(self, lambda_event, response): 120 | # type: (EventType, Dict[str, Any]) -> None 121 | self.send_response(response['statusCode']) 122 | self.send_header('Content-Length', str(len(response['body']))) 123 | self.send_header( 124 | 'Content-Type', 125 | response['headers'].get('Content-Type', 'application/json')) 126 | headers = response['headers'] 127 | for header in headers: 128 | self.send_header(header, headers[header]) 129 | self.end_headers() 130 | self.wfile.write(response['body']) 131 | 132 | def _generate_lambda_event(self): 133 | # type: () -> EventType 134 | content_length = int(self.headers.get('content-length', '0')) 135 | body = None 136 | if content_length > 0: 137 | body = self.rfile.read(content_length) 138 | # mypy doesn't like dict(self.headers) so I had to use a 139 | # dictcomp instead to make it happy. 140 | converted_headers = {key: value for key, value in self.headers.items()} 141 | lambda_event = self.event_converter.create_lambda_event( 142 | method=self.command, path=self.path, headers=converted_headers, 143 | body=body, 144 | ) 145 | return lambda_event 146 | 147 | do_GET = do_PUT = do_POST = do_HEAD = do_DELETE = do_PATCH = \ 148 | _generic_handle 149 | 150 | def do_OPTIONS(self): 151 | # type: () -> None 152 | # This can either be because the user's provided an OPTIONS method 153 | # *or* this is a preflight request, which chalice automatically 154 | # sets up for you. 155 | lambda_event = self._generate_lambda_event() 156 | if self._has_user_defined_options_method(lambda_event): 157 | self._do_invoke_view_function(lambda_event) 158 | else: 159 | # Otherwise this is a preflight request which we automatically 160 | # generate. 161 | self._send_autogen_options_response() 162 | 163 | def _cors_enabled_for_route(self, lambda_event): 164 | # type: (EventType) -> bool 165 | route_key = lambda_event['requestContext']['resourcePath'] 166 | route_entry = self.app_object.routes[route_key] 167 | return route_entry.cors 168 | 169 | def _has_user_defined_options_method(self, lambda_event): 170 | # type: (EventType) -> bool 171 | route_key = lambda_event['requestContext']['resourcePath'] 172 | route_entry = self.app_object.routes[route_key] 173 | return 'OPTIONS' in route_entry.methods 174 | 175 | def _send_autogen_options_response(self): 176 | # type:() -> None 177 | self.send_response(200) 178 | self.send_header( 179 | 'Access-Control-Allow-Headers', 180 | 'Content-Type,X-Amz-Date,Authorization,' 181 | 'X-Api-Key,X-Amz-Security-Token' 182 | ) 183 | self.send_header('Access-Control-Allow-Methods', 184 | 'GET,HEAD,PUT,POST,OPTIONS') 185 | self.send_header('Access-Control-Allow-Origin', '*') 186 | self.end_headers() 187 | 188 | 189 | class LocalDevServer(object): 190 | def __init__(self, app_object, port, handler_cls=ChaliceRequestHandler, 191 | server_cls=HTTPServer): 192 | # type: (Chalice, int, HandlerCls, ServerCls) -> None 193 | self.app_object = app_object 194 | self.port = port 195 | self._wrapped_handler = functools.partial( 196 | handler_cls, app_object=app_object) 197 | self.server = server_cls(('', port), self._wrapped_handler) 198 | 199 | def handle_single_request(self): 200 | # type: () -> None 201 | self.server.handle_request() 202 | 203 | def serve_forever(self): 204 | # type: () -> None 205 | print "Serving on localhost:%s" % self.port 206 | self.server.serve_forever() 207 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don\'t have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 21 | 22 | .PHONY: help 23 | help: 24 | @echo "Please use \`make ' where is one of" 25 | @echo " html to make standalone HTML files" 26 | @echo " dirhtml to make HTML files named index.html in directories" 27 | @echo " singlehtml to make a single large HTML file" 28 | @echo " pickle to make pickle files" 29 | @echo " json to make JSON files" 30 | @echo " htmlhelp to make HTML files and a HTML help project" 31 | @echo " qthelp to make HTML files and a qthelp project" 32 | @echo " applehelp to make an Apple Help Book" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " epub3 to make an epub3" 36 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 37 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 38 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 39 | @echo " text to make text files" 40 | @echo " man to make manual pages" 41 | @echo " texinfo to make Texinfo files" 42 | @echo " info to make Texinfo files and run them through makeinfo" 43 | @echo " gettext to make PO message catalogs" 44 | @echo " changes to make an overview of all changed/added/deprecated items" 45 | @echo " xml to make Docutils-native XML files" 46 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 47 | @echo " linkcheck to check all external links for integrity" 48 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 49 | @echo " coverage to run coverage check of the documentation (if enabled)" 50 | @echo " dummy to check syntax errors of document sources" 51 | 52 | .PHONY: clean 53 | clean: 54 | rm -rf $(BUILDDIR)/* 55 | 56 | .PHONY: html 57 | html: 58 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 61 | 62 | .PHONY: dirhtml 63 | dirhtml: 64 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 65 | @echo 66 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 67 | 68 | .PHONY: singlehtml 69 | singlehtml: 70 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 71 | @echo 72 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 73 | 74 | .PHONY: pickle 75 | pickle: 76 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 77 | @echo 78 | @echo "Build finished; now you can process the pickle files." 79 | 80 | .PHONY: json 81 | json: 82 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 83 | @echo 84 | @echo "Build finished; now you can process the JSON files." 85 | 86 | .PHONY: htmlhelp 87 | htmlhelp: 88 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 89 | @echo 90 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 91 | ".hhp project file in $(BUILDDIR)/htmlhelp." 92 | 93 | .PHONY: qthelp 94 | qthelp: 95 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 96 | @echo 97 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 98 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 99 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Chalice.qhcp" 100 | @echo "To view the help file:" 101 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Chalice.qhc" 102 | 103 | .PHONY: applehelp 104 | applehelp: 105 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 106 | @echo 107 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 108 | @echo "N.B. You won't be able to view it unless you put it in" \ 109 | "~/Library/Documentation/Help or install it in your application" \ 110 | "bundle." 111 | 112 | .PHONY: devhelp 113 | devhelp: 114 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 115 | @echo 116 | @echo "Build finished." 117 | @echo "To view the help file:" 118 | @echo "# mkdir -p $$HOME/.local/share/devhelp/Chalice" 119 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Chalice" 120 | @echo "# devhelp" 121 | 122 | .PHONY: epub 123 | epub: 124 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 125 | @echo 126 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 127 | 128 | .PHONY: epub3 129 | epub3: 130 | $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3 131 | @echo 132 | @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3." 133 | 134 | .PHONY: latex 135 | latex: 136 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 137 | @echo 138 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 139 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 140 | "(use \`make latexpdf' here to do that automatically)." 141 | 142 | .PHONY: latexpdf 143 | latexpdf: 144 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 145 | @echo "Running LaTeX files through pdflatex..." 146 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 147 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 148 | 149 | .PHONY: latexpdfja 150 | latexpdfja: 151 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 152 | @echo "Running LaTeX files through platex and dvipdfmx..." 153 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 154 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 155 | 156 | .PHONY: text 157 | text: 158 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 159 | @echo 160 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 161 | 162 | .PHONY: man 163 | man: 164 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 165 | @echo 166 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 167 | 168 | .PHONY: texinfo 169 | texinfo: 170 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 171 | @echo 172 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 173 | @echo "Run \`make' in that directory to run these through makeinfo" \ 174 | "(use \`make info' here to do that automatically)." 175 | 176 | .PHONY: info 177 | info: 178 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 179 | @echo "Running Texinfo files through makeinfo..." 180 | make -C $(BUILDDIR)/texinfo info 181 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 182 | 183 | .PHONY: gettext 184 | gettext: 185 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 186 | @echo 187 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 188 | 189 | .PHONY: changes 190 | changes: 191 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 192 | @echo 193 | @echo "The overview file is in $(BUILDDIR)/changes." 194 | 195 | .PHONY: linkcheck 196 | linkcheck: 197 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 198 | @echo 199 | @echo "Link check complete; look for any errors in the above output " \ 200 | "or in $(BUILDDIR)/linkcheck/output.txt." 201 | 202 | .PHONY: doctest 203 | doctest: 204 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 205 | @echo "Testing of doctests in the sources finished, look at the " \ 206 | "results in $(BUILDDIR)/doctest/output.txt." 207 | 208 | .PHONY: coverage 209 | coverage: 210 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 211 | @echo "Testing of coverage in the sources finished, look at the " \ 212 | "results in $(BUILDDIR)/coverage/python.txt." 213 | 214 | .PHONY: xml 215 | xml: 216 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 217 | @echo 218 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 219 | 220 | .PHONY: pseudoxml 221 | pseudoxml: 222 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 223 | @echo 224 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 225 | 226 | .PHONY: dummy 227 | dummy: 228 | $(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy 229 | @echo 230 | @echo "Build finished. Dummy builder generates no files." 231 | -------------------------------------------------------------------------------- /chalice/deploy/packager.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import inspect 3 | import os 4 | import shutil 5 | import subprocess 6 | import zipfile 7 | 8 | import virtualenv 9 | from typing import Any, Optional # noqa 10 | 11 | import chalice 12 | from chalice import compat, app 13 | 14 | 15 | class LambdaDeploymentPackager(object): 16 | _CHALICE_LIB_DIR = 'chalicelib' 17 | _VENDOR_DIR = 'vendor' 18 | 19 | def _create_virtualenv(self, venv_dir): 20 | # type: (str) -> None 21 | virtualenv.create_environment(venv_dir) 22 | 23 | def create_deployment_package(self, project_dir, package_filename=None): 24 | # type: (str, Optional[str]) -> str 25 | print "Creating deployment package." 26 | # pip install -t doesn't work out of the box with homebrew and 27 | # python, so we're using virtualenvs instead which works in 28 | # more cases. 29 | venv_dir = os.path.join(project_dir, '.chalice', 'venv') 30 | self._create_virtualenv(venv_dir) 31 | pip_exe = compat.pip_script_in_venv(venv_dir) 32 | assert os.path.isfile(pip_exe) 33 | # Next install any requirements specified by the app. 34 | requirements_file = os.path.join(project_dir, 'requirements.txt') 35 | deployment_package_filename = self.deployment_package_filename( 36 | project_dir) 37 | if package_filename is None: 38 | package_filename = deployment_package_filename 39 | if self._has_at_least_one_package(requirements_file) and not \ 40 | os.path.isfile(package_filename): 41 | p = subprocess.Popen([pip_exe, 'install', '-r', requirements_file], 42 | stdout=subprocess.PIPE) 43 | p.communicate() 44 | deps_dir = compat.site_packages_dir_in_venv(venv_dir) 45 | assert os.path.isdir(deps_dir) 46 | # Now we need to create a zip file and add in the site-packages 47 | # dir first, followed by the app_dir contents next. 48 | dirname = os.path.dirname(os.path.abspath(package_filename)) 49 | if not os.path.isdir(dirname): 50 | os.makedirs(dirname) 51 | with zipfile.ZipFile(package_filename, 'w', 52 | compression=zipfile.ZIP_DEFLATED) as z: 53 | self._add_py_deps(z, deps_dir) 54 | self._add_app_files(z, project_dir) 55 | self._add_vendor_files(z, os.path.join(project_dir, 56 | self._VENDOR_DIR)) 57 | return package_filename 58 | 59 | def _add_vendor_files(self, zipped, dirname): 60 | # type: (zipfile.ZipFile, str) -> None 61 | if not os.path.isdir(dirname): 62 | return 63 | prefix_len = len(dirname) + 1 64 | for root, _, filenames in os.walk(dirname): 65 | for filename in filenames: 66 | full_path = os.path.join(root, filename) 67 | zip_path = full_path[prefix_len:] 68 | zipped.write(full_path, zip_path) 69 | 70 | def _has_at_least_one_package(self, filename): 71 | # type: (str) -> bool 72 | if not os.path.isfile(filename): 73 | return False 74 | with open(filename, 'r') as f: 75 | # This is meant to be a best effort attempt. 76 | # This can return True and still have no packages 77 | # actually being specified, but those aren't common 78 | # cases. 79 | for line in f: 80 | line = line.strip() 81 | if line and not line.startswith('#'): 82 | return True 83 | return False 84 | 85 | def deployment_package_filename(self, project_dir): 86 | # type: (str) -> str 87 | # Computes the name of the deployment package zipfile 88 | # based on a hash of the requirements file. 89 | # This is done so that we only "pip install -r requirements.txt" 90 | # when we know there's new dependencies we need to install. 91 | requirements_file = os.path.join(project_dir, 'requirements.txt') 92 | hash_contents = self._hash_project_dir( 93 | requirements_file, os.path.join(project_dir, self._VENDOR_DIR)) 94 | deployment_package_filename = os.path.join( 95 | project_dir, '.chalice', 'deployments', hash_contents + '.zip') 96 | return deployment_package_filename 97 | 98 | def _add_py_deps(self, zip, deps_dir): 99 | # type: (zipfile.ZipFile, str) -> None 100 | prefix_len = len(deps_dir) + 1 101 | for root, dirnames, filenames in os.walk(deps_dir): 102 | if root == deps_dir and 'chalice' in dirnames: 103 | # Don't include any chalice deps. We cherry pick 104 | # what we want to include in _add_app_files. 105 | dirnames.remove('chalice') 106 | for filename in filenames: 107 | full_path = os.path.join(root, filename) 108 | zip_path = full_path[prefix_len:] 109 | zip.write(full_path, zip_path) 110 | 111 | def _add_app_files(self, zip, project_dir): 112 | # type: (zipfile.ZipFile, str) -> None 113 | chalice_router = inspect.getfile(app) 114 | if chalice_router.endswith('.pyc'): 115 | chalice_router = chalice_router[:-1] 116 | zip.write(chalice_router, 'chalice/app.py') 117 | 118 | chalice_init = inspect.getfile(chalice) 119 | if chalice_init.endswith('.pyc'): 120 | chalice_init = chalice_init[:-1] 121 | zip.write(chalice_init, 'chalice/__init__.py') 122 | 123 | zip.write(os.path.join(project_dir, 'app.py'), 124 | 'app.py') 125 | self._add_chalice_lib_if_needed(project_dir, zip) 126 | 127 | def _hash_project_dir(self, requirements_file, vendor_dir): 128 | # type: (str, str) -> str 129 | if not os.path.isfile(requirements_file): 130 | contents = '' 131 | else: 132 | with open(requirements_file) as f: 133 | contents = f.read() 134 | h = hashlib.md5(contents) 135 | if os.path.isdir(vendor_dir): 136 | self._hash_vendor_dir(vendor_dir, h) 137 | return h.hexdigest() 138 | 139 | def _hash_vendor_dir(self, vendor_dir, md5): 140 | # type: (str, Any) -> None 141 | for rootdir, dirnames, filenames in os.walk(vendor_dir): 142 | for filename in filenames: 143 | fullpath = os.path.join(rootdir, filename) 144 | with open(fullpath, 'rb') as f: 145 | for chunk in iter(lambda: f.read(1024 * 1024), b''): 146 | md5.update(chunk) 147 | 148 | def inject_latest_app(self, deployment_package_filename, project_dir): 149 | # type: (str, str) -> None 150 | """Inject latest version of chalice app into a zip package. 151 | 152 | This method takes a pre-created deployment package and injects 153 | in the latest chalice app code. This is useful in the case where 154 | you have no new package deps but have updated your chalice app code. 155 | 156 | :type deployment_package_filename: str 157 | :param deployment_package_filename: The zipfile of the 158 | preexisting deployment package. 159 | 160 | :type project_dir: str 161 | :param project_dir: Path to chalice project dir. 162 | 163 | """ 164 | # Use the premade zip file and replace the app.py file 165 | # with the latest version. Python's zipfile does not have 166 | # a way to do this efficiently so we need to create a new 167 | # zip file that has all the same stuff except for the new 168 | # app file. 169 | print "Regen deployment package..." 170 | tmpzip = deployment_package_filename + '.tmp.zip' 171 | with zipfile.ZipFile(deployment_package_filename, 'r') as inzip: 172 | with zipfile.ZipFile(tmpzip, 'w') as outzip: 173 | for el in inzip.infolist(): 174 | if self._needs_latest_version(el.filename): 175 | continue 176 | else: 177 | contents = inzip.read(el.filename) 178 | outzip.writestr(el, contents) 179 | # Then at the end, add back the app.py, chalicelib, 180 | # and runtime files. 181 | self._add_app_files(outzip, project_dir) 182 | shutil.move(tmpzip, deployment_package_filename) 183 | 184 | def _needs_latest_version(self, filename): 185 | # type: (str) -> bool 186 | return filename == 'app.py' or filename.startswith( 187 | ('chalicelib/', 'chalice/')) 188 | 189 | def _add_chalice_lib_if_needed(self, project_dir, zip): 190 | # type: (str, zipfile.ZipFile) -> None 191 | libdir = os.path.join(project_dir, self._CHALICE_LIB_DIR) 192 | if os.path.isdir(libdir): 193 | for rootdir, dirnames, filenames in os.walk(libdir): 194 | for filename in filenames: 195 | fullpath = os.path.join(rootdir, filename) 196 | zip_path = os.path.join( 197 | self._CHALICE_LIB_DIR, 198 | fullpath[len(libdir) + 1:]) 199 | zip.write(fullpath, zip_path) 200 | -------------------------------------------------------------------------------- /docs/source/upgrading.rst: -------------------------------------------------------------------------------- 1 | Upgrade Notes 2 | ============= 3 | 4 | This document provides additional documentation 5 | on upgrading your version of chalice. If you're just 6 | interested in the high level changes, see the 7 | `CHANGELOG.rst `__) 8 | file. 9 | 10 | 11 | .. _v0-7-0: 12 | 13 | 0.7.0 14 | ----- 15 | 16 | The 0.7.0 release adds several major features to chalice. While the majority 17 | of these features are introduced in a backwards compatible way, there are a few 18 | backwards incompatible changes that were made in order to support these new 19 | major features. 20 | 21 | Separate Stages 22 | ~~~~~~~~~~~~~~~ 23 | 24 | Prior to this version, chalice had a notion of a "stage" that corresponded to 25 | an API gateway stage. You can create and deploy a new API gateway stage by 26 | running ``chalice deploy ``. In 0.7.0, stage support was been 27 | reworked such that a chalice stage is a completely separate set of AWS 28 | resources. This means that if you have two chalice stages, say ``dev`` and 29 | ``prod``, then you will have two separate sets of AWS resources, one set per 30 | stage: 31 | 32 | * Two API Gateway Rest APIs 33 | * Two separate Lambda functions 34 | * Two separate IAM roles 35 | 36 | The :doc:`topics/stages` doc has more details on the new chalice stages 37 | feature. This section highlights the key differences between the old stage 38 | behavior and the new chalice stage functionality in 0.7.0. In order to ease 39 | transition to this new model, the following changes were made: 40 | 41 | * A new ``--stage`` argument was added to the ``deploy``, ``logs``, ``url``, 42 | ``generate-sdk``, and ``package`` commands. If this value is specified 43 | and the stage does not exist, a new chalice stage with that name will 44 | be created for you. 45 | * The existing form ``chalice deploy `` has been deprecated. 46 | The command will still work in version 0.7.0, but a deprecation warning 47 | will be printed to stderr. 48 | * If you want the pre-existing behavior of creating a new API gateway stage 49 | (while using the same Lambda function), you can use the 50 | ``--api-gateway-stage`` argument. This is the replacement for the 51 | deprecated form ``chalice deploy ``. 52 | * The default stage if no ``--stage`` option is provided is ``dev``. By 53 | defaulting to a ``dev`` stage, the pre-existing behavior of not 54 | specifying a stage name, e.g ``chalice deploy``, ``chalice url``, etc. 55 | will still work exactly the same. 56 | * A new ``stages`` key is supported in the ``.chalice/config.json``. This 57 | allows you to specify configuration specific to a chalice stage. 58 | See the :doc:`topics/configfile` doc for more information about stage 59 | specific configuration. 60 | * Setting ``autogen_policy`` to false will result in chalice looking 61 | for a IAM policy file named ``.chalice/policy-.json``. 62 | Previously it would look for a file named ``.chalice/policy.json``. 63 | You can also explicitly set this value to 64 | In order to ease transition, chalice will check for a 65 | ``.chalice/policy.json`` file when depoying to the ``dev`` stage. 66 | Support for ``.chalice/policy.json`` will be removed in future 67 | versions of chalice and users are encouraged to switch to the 68 | stage specific ``.chalice/policy-.json`` files. 69 | 70 | 71 | See the :doc:`topics/stages` doc for more details on the new chalice stages 72 | feature. 73 | 74 | **Note, the AWS resource names it creates now have the form 75 | ``-``, e.g. ``myapp-dev``, ``myapp-prod``.** 76 | 77 | We recommend using the new stage specific resource names. However, If you 78 | would like to use the existing resource names for a specific stage, you can 79 | create a ``.chalice/deployed.json`` file that specifies the existing values:: 80 | 81 | { 82 | "dev": { 83 | "backend": "api", 84 | "api_handler_arn": "lambda-function-arn", 85 | "api_handler_name": "lambda-function-name", 86 | "rest_api_id": "your-rest-api-id", 87 | "api_gateway_stage": "dev", 88 | "region": "your region (e.g us-west-2)", 89 | "chalice_version": "0.7.0", 90 | } 91 | } 92 | 93 | 94 | This file is discussed in the next section. 95 | 96 | Deployed Values 97 | ~~~~~~~~~~~~~~~ 98 | 99 | In version 0.7.0, the way deployed values are stored and retrieved 100 | has changed. In prior versions, only the ``lambda_arn`` was saved, 101 | and its value was written to the ``.chalice/config.json`` file. 102 | Any of other deployed values that were needed (for example the 103 | API Gateway rest API id) was dynamically queried by assuming the 104 | resource names matches the app name. In this version of chalice, 105 | a separate ``.chalice/deployed.json`` file is written on every 106 | deployement which contains all the resources that have been created. 107 | While this should be a transparent change, you may noticed 108 | issues if you run commands such as ``chalice url`` and ``chalice logs`` 109 | without first deploying. To fix this issue, run ``chalice deploy`` 110 | and version 0.7.0 of chalice so a ``.chalice/deployed.json`` will 111 | be created for you. 112 | 113 | 114 | Authorizer Changes 115 | ~~~~~~~~~~~~~~~~~~ 116 | 117 | **The ``authorizer_id`` and ``authorization_type`` args are 118 | no longer supported in ``@app.route(...)`` calls.** 119 | 120 | 121 | They have been replaced with an ``authorizer_name`` parameter and an 122 | ``app.define_authorizer`` method. 123 | 124 | This version changed the internals of how an API gateway REST API is created. 125 | Prior to 0.7.0, the AWS SDK for Python was used to make the appropriate service 126 | API calls to API gateway include ``create_rest_api`` and ``put_method / 127 | put_method_response`` for each route. In version 0.7.0, this internal 128 | mechanism was changed to instead generate a swagger document. The rest api is 129 | then created or updated by calling ``import_rest_api`` or ``put_rest_api`` and 130 | providing the swagger document. This simplifies the internals and also unifies 131 | the code base for the newly added ``chalice package`` command (which uses a 132 | swagger document internally). One consequence of this change is that the 133 | entire REST API must be defined in the swagger document. With the previous 134 | ``authorizer_id`` parameter, you would create/deploy a rest api, create your 135 | authorizer, and then provide that ``authorizer_id`` in your ``@app.route`` 136 | calls. Now they must be defined all at once in the ``app.py`` file: 137 | 138 | 139 | .. code-block:: python 140 | 141 | app = chalice.Chalice(app_name='demo') 142 | 143 | @app.route('/auth-required', authorizer_name='MyUserPool') 144 | def foo(): 145 | return {} 146 | 147 | app.define_authorizer( 148 | name='MyUserPool', 149 | header='Authorization', 150 | auth_type='cognito_user_pools', 151 | provider_arns=['arn:aws:cognito:...:userpool/name'] 152 | ) 153 | 154 | 155 | .. _v0-6-0: 156 | 157 | 0.6.0 158 | ----- 159 | 160 | This version changed how the internals of how API gateway resources are created 161 | by chalice. The integration type changed from ``AWS`` to ``AWS_PROXY``. This 162 | was to enable additional functionality, notable to allows users to provide 163 | non-JSON HTTP responses and inject arbitrary headers to the HTTP responses. 164 | While this change to the internals is primarily internal, there are several 165 | user-visible changes. 166 | 167 | 168 | * Uncaught exceptions with ``app.debug = False`` (the default value) 169 | will result in a more generic ``InternalServerError`` error. The 170 | previous behavior was to return a ``ChaliceViewError``. 171 | * When you enabled debug mode via ``app.debug = True``, the HTTP 172 | response will contain the python stack trace as the entire request 173 | body. This is to improve the readability of stack traces. 174 | For example:: 175 | 176 | $ http https://endpoint/dev/ 177 | HTTP/1.1 500 Internal Server Error 178 | Content-Length: 358 179 | Content-Type: text/plain 180 | 181 | Traceback (most recent call last): 182 | File "/var/task/chalice/app.py", line 286, in __call__ 183 | response = view_function(*function_args) 184 | File "/var/task/app.py", line 12, in index 185 | return a() 186 | File "/var/task/app.py", line 16, in a 187 | return b() 188 | File "/var/task/app.py", line 19, in b 189 | raise ValueError("Hello, error!") 190 | ValueError: Hello, error! 191 | 192 | * Content type validation now has error responses that match the same error 193 | response format used for other chalice built in responses. Chalice was 194 | previously relying on API gateway to perform the content type validation. 195 | As a result of the ``AWS_PROXY`` work, this logic has moved into the chalice 196 | handler and now has a consistent error response:: 197 | 198 | $ http https://endpoint/dev/ 'Content-Type: text/plain' 199 | HTTP/1.1 415 Unsupported Media Type 200 | Content-Type: application/json 201 | 202 | { 203 | "Code": "UnsupportedMediaType", 204 | "Message": "Unsupported media type: text/plain" 205 | } 206 | * The keys in the ``app.current_request.to_dict()`` now match the casing used 207 | by the ``AWS_PPROXY`` lambda integration, which are ``lowerCamelCased``. 208 | This method is primarily intended for introspection purposes. 209 | -------------------------------------------------------------------------------- /chalice/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | 4 | from typing import Dict, Any, Optional # noqa 5 | from chalice.app import Chalice # noqa 6 | from chalice.constants import DEFAULT_STAGE_NAME 7 | 8 | 9 | StrMap = Dict[str, Any] 10 | 11 | 12 | class Config(object): 13 | """Configuration information for a chalice app. 14 | 15 | Configuration values for a chalice app can come from 16 | a number of locations, files on disk, CLI params, default 17 | values, etc. This object is an abstraction that normalizes 18 | these values. 19 | 20 | In general, there's a precedence for looking up 21 | config values: 22 | 23 | * User specified params 24 | * Config file values 25 | * Default values 26 | 27 | A user specified parameter would mean values explicitly 28 | specified by a user. Generally these come from command 29 | line parameters (e.g ``--profile prod``), but for the purposes 30 | of this object would also mean values passed explicitly to 31 | this config object when instantiated. 32 | 33 | Additionally, there are some configurations that can vary 34 | per chalice stage (note that a chalice stage is different 35 | from an api gateway stage). For config values loaded from 36 | disk, we allow values to be specified for all stages or 37 | for a specific stage. For example, take ``environment_variables``. 38 | You can set this as a top level key to specify env vars 39 | to set for all stages, or you can set this value per chalice 40 | stage to set stage-specific environment variables. Consider 41 | this config file:: 42 | 43 | { 44 | "environment_variables": { 45 | "TABLE": "foo" 46 | }, 47 | "stages": { 48 | "dev": { 49 | "environment_variables": { 50 | "S3BUCKET": "devbucket" 51 | } 52 | }, 53 | "prod": { 54 | "environment_variables": { 55 | "S3BUCKET": "prodbucket", 56 | "TABLE": "prodtable" 57 | } 58 | } 59 | } 60 | } 61 | 62 | If the currently configured chalice stage is "dev", then 63 | the config.environment_variables would be:: 64 | 65 | {"TABLE": "foo", "S3BUCKET": "devbucket"} 66 | 67 | The "prod" stage would be:: 68 | 69 | {"TABLE": "prodtable", "S3BUCKET": "prodbucket"} 70 | 71 | """ 72 | def __init__(self, 73 | chalice_stage=DEFAULT_STAGE_NAME, 74 | user_provided_params=None, 75 | config_from_disk=None, 76 | default_params=None): 77 | # type: (str, StrMap, StrMap, StrMap) -> None 78 | #: Params that a user provided explicitly, 79 | #: typically via the command line. 80 | self.chalice_stage = chalice_stage 81 | if user_provided_params is None: 82 | user_provided_params = {} 83 | self._user_provided_params = user_provided_params 84 | #: The json.loads() from .chalice/config.json 85 | if config_from_disk is None: 86 | config_from_disk = {} 87 | self._config_from_disk = config_from_disk 88 | if default_params is None: 89 | default_params = {} 90 | self._default_params = default_params 91 | 92 | @classmethod 93 | def create(cls, chalice_stage=DEFAULT_STAGE_NAME, **kwargs): 94 | # type: (str, **Any) -> Config 95 | return cls(chalice_stage=chalice_stage, 96 | user_provided_params=kwargs.copy()) 97 | 98 | @property 99 | def profile(self): 100 | # type: () -> str 101 | return self._chain_lookup('profile') 102 | 103 | @property 104 | def app_name(self): 105 | # type: () -> str 106 | return self._chain_lookup('app_name') 107 | 108 | @property 109 | def project_dir(self): 110 | # type: () -> str 111 | return self._chain_lookup('project_dir') 112 | 113 | @property 114 | def chalice_app(self): 115 | # type: () -> Chalice 116 | return self._chain_lookup('chalice_app') 117 | 118 | @property 119 | def config_from_disk(self): 120 | # type: () -> StrMap 121 | return self._config_from_disk 122 | 123 | @property 124 | def iam_policy_file(self): 125 | # type: () -> str 126 | return self._chain_lookup('iam_policy_file', 127 | varies_per_chalice_stage=True) 128 | 129 | def _chain_lookup(self, name, varies_per_chalice_stage=False): 130 | # type: (str, bool) -> Any 131 | search_dicts = [self._user_provided_params] 132 | if varies_per_chalice_stage: 133 | search_dicts.append( 134 | self._config_from_disk.get('stages', {}).get( 135 | self.chalice_stage, {})) 136 | search_dicts.extend([self._config_from_disk, self._default_params]) 137 | for cfg_dict in search_dicts: 138 | if isinstance(cfg_dict, dict) and cfg_dict.get(name) is not None: 139 | return cfg_dict[name] 140 | 141 | def _chain_merge(self, name): 142 | # type: (str) -> Dict[str, Any] 143 | # Merge values for all search dicts instead of returning on first 144 | # found. 145 | search_dicts = [ 146 | # This is reverse order to _chain_lookup(). 147 | self._default_params, 148 | self._config_from_disk, 149 | self._config_from_disk.get('stages', {}).get( 150 | self.chalice_stage, {}), 151 | self._user_provided_params, 152 | ] 153 | final = {} 154 | for cfg_dict in search_dicts: 155 | value = cfg_dict.get(name, {}) 156 | if isinstance(value, dict): 157 | final.update(value) 158 | return final 159 | 160 | @property 161 | def config_file_version(self): 162 | # type: () -> str 163 | return self._config_from_disk.get('version', '1.0') 164 | 165 | # These are all config values that can vary per 166 | # chalice stage. 167 | 168 | @property 169 | def api_gateway_stage(self): 170 | # type: () -> str 171 | return self._chain_lookup('api_gateway_stage', 172 | varies_per_chalice_stage=True) 173 | 174 | @property 175 | def iam_role_arn(self): 176 | # type: () -> str 177 | return self._chain_lookup('iam_role_arn', 178 | varies_per_chalice_stage=True) 179 | 180 | @property 181 | def manage_iam_role(self): 182 | # type: () -> bool 183 | result = self._chain_lookup('manage_iam_role', 184 | varies_per_chalice_stage=True) 185 | if result is None: 186 | # To simplify downstream code, if manage_iam_role 187 | # is None (indicating the user hasn't configured/specified this 188 | # value anywhere), then we'll return a default value of True. 189 | # Otherwise client code has to do an awkward 190 | # "if manage_iam_role is None and not manage_iam_role". 191 | return True 192 | return result 193 | 194 | @property 195 | def autogen_policy(self): 196 | # type: () -> bool 197 | return self._chain_lookup('autogen_policy', 198 | varies_per_chalice_stage=True) 199 | 200 | @property 201 | def environment_variables(self): 202 | # type: () -> Dict[str, str] 203 | return self._chain_merge('environment_variables') 204 | 205 | def deployed_resources(self, chalice_stage_name): 206 | # type: (str) -> Optional[DeployedResources] 207 | """Return resources associated with a given stage. 208 | 209 | If a deployment to a given stage has never happened, 210 | this method will return a value of None. 211 | 212 | """ 213 | # This is arguably the wrong level of abstraction. 214 | # We might be able to move this elsewhere. 215 | deployed_file = os.path.join(self.project_dir, '.chalice', 216 | 'deployed.json') 217 | if not os.path.isfile(deployed_file): 218 | return None 219 | with open(deployed_file, 'r') as f: 220 | data = json.load(f) 221 | if chalice_stage_name not in data: 222 | return None 223 | return DeployedResources.from_dict(data[chalice_stage_name]) 224 | 225 | 226 | class DeployedResources(object): 227 | def __init__(self, backend, api_handler_arn, 228 | api_handler_name, rest_api_id, api_gateway_stage, 229 | region, chalice_version): 230 | # type: (str, str, str, str, str, str, str) -> None 231 | self.backend = backend 232 | self.api_handler_arn = api_handler_arn 233 | self.api_handler_name = api_handler_name 234 | self.rest_api_id = rest_api_id 235 | self.api_gateway_stage = api_gateway_stage 236 | self.region = region 237 | self.chalice_version = chalice_version 238 | 239 | @classmethod 240 | def from_dict(cls, data): 241 | # type: (Dict[str, str]) -> DeployedResources 242 | return cls( 243 | data['backend'], 244 | data['api_handler_arn'], 245 | data['api_handler_name'], 246 | data['rest_api_id'], 247 | data['api_gateway_stage'], 248 | data['region'], 249 | data['chalice_version'], 250 | ) 251 | -------------------------------------------------------------------------------- /tests/functional/test_deployer.py: -------------------------------------------------------------------------------- 1 | import os 2 | import zipfile 3 | 4 | import botocore.session 5 | from pytest import fixture 6 | import pytest 7 | 8 | import chalice.deploy.packager 9 | import chalice.utils 10 | from chalice.deploy import deployer 11 | 12 | 13 | slow = pytest.mark.skipif( 14 | pytest.config.getoption('--skip-slow'), 15 | reason='Skipped due to --skip-slow') 16 | 17 | 18 | @fixture 19 | def chalice_deployer(): 20 | d = chalice.deploy.packager.LambdaDeploymentPackager() 21 | return d 22 | 23 | 24 | def _create_app_structure(tmpdir): 25 | appdir = tmpdir.mkdir('app') 26 | appdir.join('app.py').write('# Test app') 27 | appdir.mkdir('.chalice') 28 | return appdir 29 | 30 | 31 | @slow 32 | def test_can_create_deployment_package(tmpdir, chalice_deployer): 33 | appdir = _create_app_structure(tmpdir) 34 | appdir.join('app.py').write('# Test app') 35 | chalice_dir = appdir.join('.chalice') 36 | chalice_deployer.create_deployment_package(str(appdir)) 37 | # There should now be a zip file created. 38 | contents = chalice_dir.join('deployments').listdir() 39 | assert len(contents) == 1 40 | assert str(contents[0]).endswith('.zip') 41 | 42 | 43 | @slow 44 | def test_can_inject_latest_app(tmpdir, chalice_deployer): 45 | appdir = _create_app_structure(tmpdir) 46 | appdir.join('app.py').write('# Test app v1') 47 | chalice_dir = appdir.join('.chalice') 48 | name = chalice_deployer.create_deployment_package(str(appdir)) 49 | 50 | # Now suppose we update our app code but not any deps. 51 | # We can use inject_latest_app. 52 | appdir.join('app.py').write('# Test app NEW VERSION') 53 | # There should now be a zip file created. 54 | chalice_deployer.inject_latest_app(name, str(appdir)) 55 | contents = chalice_dir.join('deployments').listdir() 56 | assert len(contents) == 1 57 | assert str(contents[0]) == name 58 | with zipfile.ZipFile(name) as f: 59 | contents = f.read('app.py') 60 | assert contents == '# Test app NEW VERSION' 61 | 62 | 63 | @slow 64 | def test_app_injection_still_compresses_file(tmpdir, chalice_deployer): 65 | appdir = _create_app_structure(tmpdir) 66 | appdir.join('app.py').write('# Test app v1') 67 | name = chalice_deployer.create_deployment_package(str(appdir)) 68 | original_size = os.path.getsize(name) 69 | appdir.join('app.py').write('# Test app v2') 70 | chalice_deployer.inject_latest_app(name, str(appdir)) 71 | new_size = os.path.getsize(name) 72 | # The new_size won't be exactly the same as the original, 73 | # we just want to make sure it wasn't converted to 74 | # ZIP_STORED, so there's a 5% tolerance. 75 | assert new_size < (original_size * 1.05) 76 | 77 | 78 | @slow 79 | def test_no_error_message_printed_on_empty_reqs_file(tmpdir, 80 | chalice_deployer, 81 | capfd): 82 | appdir = _create_app_structure(tmpdir) 83 | appdir.join('app.py').write('# Foo') 84 | appdir.join('requirements.txt').write('\n') 85 | chalice_deployer.create_deployment_package(str(appdir)) 86 | out, err = capfd.readouterr() 87 | assert err.strip() == '' 88 | 89 | 90 | def test_can_create_deployer_from_factory_function(): 91 | session = botocore.session.get_session() 92 | d = deployer.create_default_deployer(session) 93 | assert isinstance(d, deployer.Deployer) 94 | 95 | 96 | def test_osutils_proxies_os_functions(tmpdir): 97 | appdir = _create_app_structure(tmpdir) 98 | appdir.join('app.py').write(b'hello') 99 | 100 | osutils = chalice.utils.OSUtils() 101 | 102 | app_file = str(appdir.join('app.py')) 103 | assert osutils.file_exists(app_file) 104 | assert osutils.get_file_contents(app_file) == b'hello' 105 | assert osutils.open(app_file, 'rb').read() == b'hello' 106 | osutils.remove_file(app_file) 107 | # Removing again doesn't raise an error. 108 | osutils.remove_file(app_file) 109 | assert not osutils.file_exists(app_file) 110 | 111 | 112 | @slow 113 | def test_includes_app_and_chalicelib_dir(tmpdir, chalice_deployer): 114 | appdir = _create_app_structure(tmpdir) 115 | # We're now also going to create additional files 116 | chalicelib = appdir.mkdir('chalicelib') 117 | appdir.join('chalicelib', '__init__.py').write('# Test package') 118 | appdir.join('chalicelib', 'mymodule.py').write('# Test module') 119 | appdir.join('chalicelib', 'config.json').write('{"test": "config"}') 120 | # Should also include sub directories 121 | subdir = chalicelib.mkdir('subdir') 122 | subdir.join('submodule.py').write('# Test submodule') 123 | subdir.join('subconfig.json').write('{"test": "subconfig"}') 124 | name = chalice_deployer.create_deployment_package(str(appdir)) 125 | with zipfile.ZipFile(name) as f: 126 | _assert_in_zip('chalicelib/__init__.py', '# Test package', f) 127 | _assert_in_zip('chalicelib/mymodule.py', '# Test module', f) 128 | _assert_in_zip('chalicelib/config.json', '{"test": "config"}', f) 129 | _assert_in_zip('chalicelib/subdir/submodule.py', 130 | '# Test submodule', f) 131 | _assert_in_zip('chalicelib/subdir/subconfig.json', 132 | '{"test": "subconfig"}', f) 133 | 134 | 135 | def _assert_in_zip(path, contents, zip): 136 | allfiles = zip.namelist() 137 | assert path in allfiles 138 | assert zip.read(path) == contents 139 | 140 | 141 | @slow 142 | def test_subsequent_deploy_replaces_chalicelib(tmpdir, chalice_deployer): 143 | appdir = _create_app_structure(tmpdir) 144 | chalicelib = appdir.mkdir('chalicelib') 145 | appdir.join('chalicelib', '__init__.py').write('# Test package') 146 | subdir = chalicelib.mkdir('subdir') 147 | subdir.join('submodule.py').write('# Test submodule') 148 | 149 | name = chalice_deployer.create_deployment_package(str(appdir)) 150 | subdir.join('submodule.py').write('# Test submodule v2') 151 | appdir.join('chalicelib', '__init__.py').remove() 152 | chalice_deployer.inject_latest_app(name, str(appdir)) 153 | with zipfile.ZipFile(name) as f: 154 | _assert_in_zip('chalicelib/subdir/submodule.py', 155 | '# Test submodule v2', f) 156 | # And chalicelib/__init__.py should no longer be 157 | # in the zipfile because we deleted it in the appdir. 158 | assert 'chalicelib/__init__.py' not in f.namelist() 159 | 160 | 161 | @slow 162 | def test_vendor_dir_included(tmpdir, chalice_deployer): 163 | appdir = _create_app_structure(tmpdir) 164 | vendor = appdir.mkdir('vendor') 165 | extra_package = vendor.mkdir('mypackage') 166 | extra_package.join('__init__.py').write('# Test package') 167 | name = chalice_deployer.create_deployment_package(str(appdir)) 168 | with zipfile.ZipFile(name) as f: 169 | _assert_in_zip('mypackage/__init__.py', '# Test package', f) 170 | 171 | 172 | @slow 173 | def test_subsequent_deploy_replaces_vendor_dir(tmpdir, chalice_deployer): 174 | appdir = _create_app_structure(tmpdir) 175 | vendor = appdir.mkdir('vendor') 176 | extra_package = vendor.mkdir('mypackage') 177 | extra_package.join('__init__.py').write('# v1') 178 | name = chalice_deployer.create_deployment_package(str(appdir)) 179 | # Now we update a package in vendor/ with a new version. 180 | extra_package.join('__init__.py').write('# v2') 181 | name = chalice_deployer.create_deployment_package(str(appdir)) 182 | with zipfile.ZipFile(name) as f: 183 | _assert_in_zip('mypackage/__init__.py', '# v2', f) 184 | 185 | 186 | def test_zip_filename_changes_on_vendor_update(tmpdir, chalice_deployer): 187 | appdir = _create_app_structure(tmpdir) 188 | vendor = appdir.mkdir('vendor') 189 | extra_package = vendor.mkdir('mypackage') 190 | extra_package.join('__init__.py').write('# v1') 191 | first = chalice_deployer.deployment_package_filename(str(appdir)) 192 | extra_package.join('__init__.py').write('# v2') 193 | second = chalice_deployer.deployment_package_filename(str(appdir)) 194 | assert first != second 195 | 196 | 197 | @slow 198 | def test_chalice_runtime_injected_on_change(tmpdir, chalice_deployer): 199 | appdir = _create_app_structure(tmpdir) 200 | name = chalice_deployer.create_deployment_package(str(appdir)) 201 | # We're verifying that we always inject the chalice runtime 202 | # but we can't actually modify the runtime in this repo, so 203 | # instead we'll modify the deployment package and change the 204 | # runtime. 205 | # We'll then verify when we inject the latest app the runtime 206 | # has been re-added. This should give us enough confidence 207 | # that the runtime is always being inserted. 208 | _remove_runtime_from_deployment_package(name) 209 | with zipfile.ZipFile(name) as z: 210 | assert 'chalice/app.py' not in z.namelist() 211 | chalice_deployer.inject_latest_app(name, str(appdir)) 212 | with zipfile.ZipFile(name) as z: 213 | assert 'chalice/app.py' in z.namelist() 214 | 215 | 216 | def _remove_runtime_from_deployment_package(filename): 217 | new_filename = os.path.join(os.path.dirname(filename), 'new.zip') 218 | with zipfile.ZipFile(filename, 'r') as original: 219 | with zipfile.ZipFile (new_filename, 'w', 220 | compression=zipfile.ZIP_DEFLATED) as z: 221 | for item in original.infolist(): 222 | if item.filename.startswith('chalice/'): 223 | continue 224 | contents = original.read(item.filename) 225 | z.writestr(item, contents) 226 | os.rename(new_filename, filename) 227 | -------------------------------------------------------------------------------- /tests/unit/test_local.py: -------------------------------------------------------------------------------- 1 | from chalice import local, BadRequestError 2 | import json 3 | import decimal 4 | import pytest 5 | from pytest import fixture 6 | from StringIO import StringIO 7 | 8 | from chalice import app 9 | 10 | 11 | class ChaliceStubbedHandler(local.ChaliceRequestHandler): 12 | requestline = '' 13 | request_version = 'HTTP/1.1' 14 | 15 | def setup(self): 16 | self.rfile = StringIO() 17 | self.wfile = StringIO() 18 | self.requestline = '' 19 | 20 | def finish(self): 21 | pass 22 | 23 | 24 | @fixture 25 | def sample_app(): 26 | demo = app.Chalice('demo-app') 27 | demo.debug = True 28 | 29 | @demo.route('/index', methods=['GET']) 30 | def index(): 31 | return {'hello': 'world'} 32 | 33 | @demo.route('/names/{name}', methods=['GET']) 34 | def name(name): 35 | return {'provided-name': name} 36 | 37 | @demo.route('/put', methods=['PUT']) 38 | def put(): 39 | return {'body': demo.current_request.json_body} 40 | 41 | @demo.route('/cors', methods=['GET', 'PUT'], cors=True) 42 | def cors(): 43 | return {'cors': True} 44 | 45 | @demo.route('/options', methods=['OPTIONS']) 46 | def options(): 47 | return {'options': True} 48 | 49 | @demo.route('/delete', methods=['DELETE']) 50 | def delete(): 51 | return {'delete': True} 52 | 53 | @demo.route('/patch', methods=['PATCH']) 54 | def patch(): 55 | return {'patch': True} 56 | 57 | @demo.route('/badrequest') 58 | def badrequest(): 59 | raise BadRequestError('bad-request') 60 | 61 | @demo.route('/decimals') 62 | def decimals(): 63 | return decimal.Decimal('100') 64 | 65 | @demo.route('/query-string') 66 | def query_string(): 67 | return demo.current_request.query_params 68 | 69 | return demo 70 | 71 | 72 | @fixture 73 | def handler(sample_app): 74 | chalice_handler = ChaliceStubbedHandler(None, ('127.0.0.1', 2000), None, 75 | app_object=sample_app) 76 | chalice_handler.sample_app = sample_app 77 | return chalice_handler 78 | 79 | 80 | def _get_body_from_response_stream(handler): 81 | # This is going to include things like status code and 82 | # response headers in the raw stream. We just care about the 83 | # body for now so we'll split lines. 84 | raw_response = handler.wfile.getvalue() 85 | body = raw_response.splitlines()[-1] 86 | return json.loads(body) 87 | 88 | 89 | def set_current_request(handler, method, path, headers=None): 90 | if headers is None: 91 | headers = {'content-type': 'application/json'} 92 | handler.command = method 93 | handler.path = path 94 | handler.headers = headers 95 | 96 | 97 | def test_can_convert_request_handler_to_lambda_event(handler): 98 | set_current_request(handler, method='GET', path='/index') 99 | handler.do_GET() 100 | assert _get_body_from_response_stream(handler) == {'hello': 'world'} 101 | 102 | 103 | def test_can_route_url_params(handler): 104 | set_current_request(handler, method='GET', path='/names/james') 105 | handler.do_GET() 106 | assert _get_body_from_response_stream(handler) == { 107 | 'provided-name': 'james'} 108 | 109 | 110 | def test_can_route_put_with_body(handler): 111 | body = '{"foo": "bar"}' 112 | headers = {'content-type': 'application/json', 113 | 'content-length': len(body)} 114 | set_current_request(handler, method='PUT', path='/put', 115 | headers=headers) 116 | handler.rfile.write(body) 117 | handler.rfile.seek(0) 118 | 119 | handler.do_PUT() 120 | assert _get_body_from_response_stream(handler) == { 121 | 'body': {'foo': 'bar'}} 122 | 123 | 124 | def test_will_respond_with_cors_enabled(handler): 125 | headers = {'content-type': 'application/json', 'origin': 'null'} 126 | set_current_request(handler, method='GET', path='/cors', headers=headers) 127 | handler.do_GET() 128 | response_lines = handler.wfile.getvalue().splitlines() 129 | assert 'Access-Control-Allow-Origin: *' in response_lines 130 | 131 | 132 | def test_can_preflight_request(handler): 133 | headers = {'content-type': 'application/json', 'origin': 'null'} 134 | set_current_request(handler, method='OPTIONS', path='/cors', 135 | headers=headers) 136 | handler.do_OPTIONS() 137 | response_lines = handler.wfile.getvalue().splitlines() 138 | assert 'Access-Control-Allow-Origin: *' in response_lines 139 | 140 | 141 | def test_non_preflight_options_request(handler): 142 | headers = {'content-type': 'application/json', 'origin': 'null'} 143 | set_current_request(handler, method='OPTIONS', path='/options', 144 | headers=headers) 145 | handler.do_OPTIONS() 146 | assert _get_body_from_response_stream(handler) == {'options': True} 147 | 148 | 149 | def test_errors_converted_to_json_response(handler): 150 | set_current_request(handler, method='GET', path='/badrequest') 151 | handler.do_GET() 152 | assert _get_body_from_response_stream(handler) == { 153 | 'Code': 'BadRequestError', 154 | 'Message': 'BadRequestError: bad-request' 155 | } 156 | 157 | 158 | def test_can_support_delete_method(handler): 159 | set_current_request(handler, method='DELETE', path='/delete') 160 | handler.do_DELETE() 161 | assert _get_body_from_response_stream(handler) == {'delete': True} 162 | 163 | 164 | def test_can_support_patch_method(handler): 165 | set_current_request(handler, method='PATCH', path='/patch') 166 | handler.do_PATCH() 167 | assert _get_body_from_response_stream(handler) == {'patch': True} 168 | 169 | def test_can_support_decimals(handler): 170 | set_current_request(handler, method='GET', path='/decimals') 171 | handler.do_PATCH() 172 | assert _get_body_from_response_stream(handler) == 100 173 | 174 | 175 | def test_unsupported_methods_raise_error(handler): 176 | set_current_request(handler, method='POST', path='/index') 177 | handler.do_POST() 178 | assert _get_body_from_response_stream(handler) == { 179 | 'Code': 'MethodNotAllowedError', 180 | 'Message': 'Unsupported method: POST' 181 | } 182 | 183 | 184 | def test_querystring_is_mapped(handler): 185 | set_current_request(handler, method='GET', path='/query-string?a=b&c=d') 186 | handler.do_GET() 187 | assert _get_body_from_response_stream(handler) == {'a': 'b', 'c': 'd'} 188 | 189 | 190 | @pytest.mark.parametrize('actual_url,matched_url', [ 191 | ('/foo', '/foo'), 192 | ('/foo/bar', '/foo/bar'), 193 | ('/foo/other', '/foo/{capture}'), 194 | ('/names/foo', '/names/{capture}'), 195 | ('/names/bar', '/names/{capture}'), 196 | ('/nomatch', None), 197 | ('/names/bar/wrong', None), 198 | ('/a/z/c', '/a/{capture}/c'), 199 | ('/a/b/c', '/a/b/c'), 200 | ]) 201 | def test_can_match_exact_route(actual_url, matched_url): 202 | matcher = local.RouteMatcher([ 203 | '/foo', '/foo/{capture}', '/foo/bar', 204 | '/names/{capture}', 205 | '/a/{capture}/c', '/a/b/c' 206 | ]) 207 | if matched_url is not None: 208 | assert matcher.match_route(actual_url).route == matched_url 209 | else: 210 | with pytest.raises(ValueError): 211 | matcher.match_route(actual_url) 212 | 213 | 214 | def test_can_create_lambda_event(): 215 | converter = local.LambdaEventConverter( 216 | local.RouteMatcher(['/foo/bar', '/foo/{capture}'])) 217 | event = converter.create_lambda_event( 218 | method='GET', 219 | path='/foo/other', 220 | headers={'content-type': 'application/json'} 221 | ) 222 | assert event == { 223 | 'requestContext': { 224 | 'httpMethod': 'GET', 225 | 'resourcePath': '/foo/{capture}', 226 | }, 227 | 'headers': {'content-type': 'application/json'}, 228 | 'pathParameters': {'capture': 'other'}, 229 | 'queryStringParameters': {}, 230 | 'body': '{}', 231 | 'stageVariables': {}, 232 | } 233 | 234 | 235 | def test_can_create_lambda_event_for_put_request(): 236 | converter = local.LambdaEventConverter( 237 | local.RouteMatcher(['/foo/bar', '/foo/{capture}'])) 238 | event = converter.create_lambda_event( 239 | method='PUT', 240 | path='/foo/other', 241 | headers={'content-type': 'application/json'}, 242 | body='{"foo": "bar"}', 243 | ) 244 | assert event == { 245 | 'requestContext': { 246 | 'httpMethod': 'PUT', 247 | 'resourcePath': '/foo/{capture}', 248 | }, 249 | 'headers': {'content-type': 'application/json'}, 250 | 'pathParameters': {'capture': 'other'}, 251 | 'queryStringParameters': {}, 252 | 'body': '{"foo": "bar"}', 253 | 'stageVariables': {}, 254 | } 255 | 256 | 257 | def test_can_create_lambda_event_for_post_with_formencoded_body(): 258 | converter = local.LambdaEventConverter( 259 | local.RouteMatcher(['/foo/bar', '/foo/{capture}'])) 260 | form_body = 'foo=bar&baz=qux' 261 | event = converter.create_lambda_event( 262 | method='POST', 263 | path='/foo/other', 264 | headers={'content-type': 'application/x-www-form-urlencoded'}, 265 | body=form_body, 266 | ) 267 | assert event == { 268 | 'requestContext': { 269 | 'httpMethod': 'POST', 270 | 'resourcePath': '/foo/{capture}', 271 | }, 272 | 'headers': {'content-type': 'application/x-www-form-urlencoded'}, 273 | 'pathParameters': {'capture': 'other'}, 274 | 'queryStringParameters': {}, 275 | 'body': form_body, 276 | 'stageVariables': {}, 277 | } 278 | 279 | 280 | def test_can_provide_port_to_local_server(sample_app): 281 | dev_server = local.create_local_server(sample_app, port=23456) 282 | assert dev_server.server.server_port == 23456 283 | --------------------------------------------------------------------------------