├── .env.example ├── .gitignore ├── .pre-commit-config.yaml ├── .travis.yml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── app ├── __init__.py └── api │ ├── __init__.py │ ├── errors.py │ └── resources.py ├── bin ├── bundlelambda ├── deploy ├── deployterraform ├── destroy ├── lib │ └── activate-env.sh ├── output └── pollapi ├── config ├── .gitignore └── default.py ├── docker-compose.yml ├── requirements-to-freeze.txt ├── requirements.txt ├── run.py ├── run_lambda.py ├── template.yaml ├── terraform ├── .gitignore ├── main.tf ├── outputs.tf └── variables.tf ├── tests ├── __init__.py ├── apis │ ├── __init__.py │ ├── conftest.py │ ├── test_artists.py │ ├── test_artists.py.bak │ └── test_errors.py └── conftest.py └── tox.ini /.env.example: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # shellcheck disable=SC2034 3 | 4 | #################### 5 | # deployment 6 | # needed for bin scripts to work 7 | #################### 8 | # Update these 9 | ACCOUNT_ID_AWS=123456789 10 | REGION=eu-west-2 11 | ENVIRONMENT=prod 12 | 13 | # needed for bin scripts but can be left as defaults 14 | export SKIP_BUNDLE=0 15 | # export SKIP_BUNDLE=1 16 | LAMBDA_FUNCTION_NAME="HttpServer" 17 | LAMBDA_ZIP_PATH="dist/$(basename "$PWD").zip" 18 | LAMBDA_ZIP=terraform/$LAMBDA_ZIP_PATH 19 | TRUSTED_CIDRS='["0.0.0.0/0"]' 20 | 21 | 22 | #################### 23 | # local development 24 | #################### 25 | export APP_CONFIG_FILE=$PWD/config/development.py 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.sublime-* 2 | .pytest_cache 3 | tmp.sh 4 | 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | env/ 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | # lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *,cover 50 | .hypothesis/ 51 | junit.xml 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # IPython Notebook 75 | .ipynb_checkpoints 76 | 77 | # pyenv 78 | .python-version 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # dotenv 84 | .env 85 | .env.deploy 86 | 87 | # virtualenv 88 | venv/ 89 | ENV/ 90 | 91 | # Spyder project settings 92 | .spyderproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # Jetbrains IDE 98 | .idea 99 | 100 | # ZSH env 101 | .autoenv.zsh 102 | 103 | # Created by https://www.gitignore.io/api/terraform 104 | 105 | ### Terraform ### 106 | # Terraform - https://terraform.io/ 107 | .terraform 108 | terraform.tfstate 109 | terraform.tfstate.backup 110 | *.tfvars 111 | 112 | ### Terraform Patch ### 113 | # End of https://www.gitignore.io/api/terraform 114 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | - repo: https://github.com/kintoandar/pre-commit.git 2 | sha: v2.1.0 3 | hooks: 4 | - id: terraform_validate 5 | 6 | - repo: https://github.com/antonbabenko/pre-commit-terraform.git 7 | sha: v1.5.0 8 | hooks: 9 | - id: terraform_fmt 10 | 11 | - repo: git://github.com/pre-commit/pre-commit-hooks 12 | sha: 46251c9523506b68419aefdf5ff6ff2fbc4506a4 13 | hooks: 14 | - id: autopep8-wrapper 15 | - id: check-added-large-files 16 | - id: check-case-conflict 17 | - id: check-json 18 | - id: check-merge-conflict 19 | - id: check-yaml 20 | - id: debug-statements 21 | - id: detect-private-key 22 | - id: double-quote-string-fixer 23 | - id: end-of-file-fixer 24 | - id: flake8 25 | args: ['--exclude=migrations/*'] 26 | - id: forbid-new-submodules 27 | - id: requirements-txt-fixer 28 | - id: trailing-whitespace 29 | 30 | - repo: https://github.com/detailyang/pre-commit-shell.git 31 | sha: 1.0.2 32 | hooks: 33 | - id: shell-lint 34 | files: bin\/(?!waitforit) 35 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | language: python 3 | env: 4 | global: 5 | - TERRAFORM_VERSION=0.11.6 6 | 7 | python: 8 | - "3.6" 9 | 10 | addons: 11 | apt: 12 | sources: 13 | - debian-sid 14 | packages: 15 | - shellcheck 16 | 17 | before_script: 18 | - > 19 | travis_retry curl 20 | "https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_amd64.zip" 21 | > /tmp/terraform.zip 22 | - sudo unzip /tmp/terraform.zip -d /usr/bin 23 | - sudo chmod +x /usr/bin/terraform 24 | 25 | before_install: 26 | - sudo apt-get update -yqq 27 | 28 | install: 29 | - make deps 30 | 31 | script: 32 | - make lint 33 | - make test 34 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6 2 | 3 | RUN apt-get update && \ 4 | apt-get -y install dbus python-dbus-dev python3-dbus zip 5 | 6 | COPY requirements.txt /requirements.txt 7 | RUN pip install -r /requirements.txt 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Andrew Griffiths 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | COVERAGE_MIN = 50 2 | 3 | env: 4 | @python3 -m venv env 5 | 6 | #################### 7 | # run local server 8 | #################### 9 | server: 10 | @FLASK_APP=run.py flask run 11 | 12 | server-debug: 13 | @$(shell FLASK_DEBUG=1 make server) 14 | 15 | server-docker: 16 | @FLASK_APP=run.py flask run --host 0.0.0.0 17 | 18 | 19 | #################### 20 | # dependencies 21 | #################### 22 | deps: 23 | @pip install -r requirements.txt 24 | @pre-commit install 25 | 26 | deps-update: 27 | @pip install -r requirements-to-freeze.txt --upgrade 28 | @pip freeze > requirements.txt 29 | 30 | deps-uninstall: 31 | @pip uninstall -yr requirements.txt 32 | @pip freeze > requirements.txt 33 | 34 | #################### 35 | # lint 36 | #################### 37 | lint: 38 | @pre-commit run \ 39 | --allow-unstaged-config \ 40 | --all-files \ 41 | --verbose 42 | 43 | autopep8: 44 | @autopep8 . --recursive --in-place --pep8-passes 2000 --verbose 45 | 46 | autopep8-stats: 47 | @pep8 --quiet --statistics . 48 | 49 | #################### 50 | # tests 51 | #################### 52 | test: config/testing.py 53 | @pytest --cov-fail-under $(COVERAGE_MIN) --cov=app --cov-report html:htmlcov 54 | 55 | test-debug: 56 | @pytest --pdb 57 | 58 | test-deploy: 59 | @http-prompt $(shell cd terraform && terraform output api_url) 60 | 61 | clean: 62 | @find . -name '__pycache__' | xargs rm -rf 63 | 64 | .PHONY: deps* lint test* clean autopep8* migrate server* 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python Serverless API 2 | 3 | Boilerplate Flask app that is portable between different serverless platforms. 4 | 5 | ----------------------------------------------------------- 6 | ## Platforms 7 | 8 | Deployment and application code adaptors are being added for the following: 9 | 10 |
11 | 12 | | Platform | Deployment | Status | 13 | |--------------------------|-----------------------|:----------------------:| 14 | | AWS Lambda | AWS SAM | :heavy_check_mark: | 15 | | AWS Lambda | Terraform | :heavy_check_mark: | 16 | | Azure Functions | Terraform | | 17 | | Google Cloud Functions | Terraform | | 18 | | Google Kubernetes Engine | `gcloud` & `kubectl` | | 19 | 20 | 21 | | Platform | Adaptor | Code/Config | 22 | |--------------------------|-----------------------|:----------------------:| 23 | | Local Development | None | [:floppy_disk:](run.py) | 24 | | AWS Lambda Python >= 3.6 | [Flask-Lambda-Python36](https://github.com/techjacker/flask-lambda) | [:floppy_disk:](run_lambda.py)| 25 | | AWS Lambda Python <= 3.6 | [Flask-Lambda](https://github.com/sivel/flask-lambda) | [:floppy_disk:](run_lambda.py) | 26 | | Azure Functions | | | 27 | | Google Cloud Functions | | | 28 | 29 | ----------------------------------------------------------- 30 | ## Setup 31 | 32 | 33 | #### 1. Create `.env` file and update contents 34 | This is used to set the environment variables required for deployment and local development. 35 | ``` 36 | $ cp .env.example .env 37 | $ vim .env 38 | ``` 39 | 40 | #### 2. Create a virtualenv then install requirements: 41 | ``` 42 | $ make env 43 | $ source env/bin/activate 44 | $ make deps 45 | ``` 46 | 47 | ----------------------------------------------------------- 48 | ## Example Usage 49 | 50 | #### 1. Set Environment 51 | Ensure you have created your virtualenv and have the necessary environment variables set (see [setup instructions](#setup) above). 52 | ``` 53 | $ source env/bin/activate 54 | $ source .env 55 | ``` 56 | 57 | #### 2. Run server 58 | 59 | ##### On host 60 | ``` 61 | $ make server-debug 62 | ``` 63 | 64 | ##### In docker 65 | ``` 66 | $ docker-compose up 67 | ``` 68 | 69 | #### 3. Manually test development server 70 | ``` 71 | $ http-prompt localhost:5000 72 | GET /artists 73 | ``` 74 | 75 | 76 | ----------------------------------------------------------- 77 | ## AWS Lambda 78 | 79 | ### Terraform Deployment 80 | Ensure you have created your virtualenv and have the necessary environment variables set (see [setup instructions](#setup) above). 81 | 82 | ##### Setup 83 | Create terraform state bucket. 84 | ``` 85 | $ aws s3 mb --region eu-west-2 s3:// 86 | ``` 87 | 88 | Update bucket name in `/terraform/main.tf`. 89 | ``` 90 | terraform { 91 | backend "s3" { 92 | bucket = "" 93 | key = "terraform.tfstate" 94 | region = "eu-west-2" 95 | } 96 | } 97 | ``` 98 | 99 | #### Deploy 100 | Bundle the app into a zip and deploy it using terraform. 101 | ``` 102 | $ ./bin/deploy 103 | ``` 104 | 105 | #### Manually Test API 106 | ``` 107 | $ http-prompt $(cd terraform && terraform output api_url) 108 | GET artists 109 | ``` 110 | 111 | ### [AWS Serverless Application Model (SAM)](https://aws.amazon.com/about-aws/whats-new/2016/11/introducing-the-aws-serverless-application-model/) Deployment 112 | 113 | Unlike Terraform SAM doesn't upload the zip bundle so do this using the `aws-cli` tool. 114 | ```Shell 115 | $ aws s3 mb s3:// 116 | $ aws s3 cp terraform/dist/python-serverless-api.zip s3:///python-serverless-api.zip 117 | ``` 118 | 119 | Update the S3 bucket value in the SAM config. 120 | ```YAML 121 | # template.yaml 122 | AWSTemplateFormatVersion: '2010-09-09' 123 | Transform: 'AWS::Serverless-2016-10-31' 124 | Description: 'Boilerplate Python 3.6 Flask App.' 125 | Resources: 126 | FlaskAPI: 127 | Type: 'AWS::Serverless::Function' 128 | Properties: 129 | CodeUri: s3:///flask-app.zip 130 | ``` 131 | 132 | Deploy the SAM template with Cloudformation. 133 | ```Shell 134 | $ aws cloudformation deploy \ 135 | --template-file template.yaml \ 136 | --stack-name python-serverless-stack-sam 137 | --capabilities CAPABILITY_IAM 138 | ``` 139 | 140 | 141 | ----------------------------------------------------------- 142 | ## Test 143 | ``` 144 | $ make test 145 | $ make lint 146 | ``` 147 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | def create_app(Flask): 2 | # Flask takes name of directory of app source code as argument 3 | app = Flask(__name__) 4 | 5 | app.config.from_object('config.default') 6 | # do not throw error if environment variable not set 7 | app.config.from_envvar('APP_CONFIG_FILE', silent=True) 8 | 9 | from .api import api as api_blueprint 10 | app.register_blueprint(api_blueprint) 11 | 12 | return app 13 | -------------------------------------------------------------------------------- /app/api/__init__.py: -------------------------------------------------------------------------------- 1 | from .resources import api # noqa 2 | -------------------------------------------------------------------------------- /app/api/errors.py: -------------------------------------------------------------------------------- 1 | from flask import jsonify 2 | from werkzeug.http import HTTP_STATUS_CODES 3 | 4 | 5 | def error_message(code, message): 6 | """ 7 | error_message wraps an error into payload format expected by the API client 8 | """ 9 | return jsonify({ 10 | 'error': { 11 | 'code': HTTP_STATUS_CODES.get(code, code), 12 | 'message': message 13 | } 14 | }), code 15 | 16 | 17 | def unprocessable_entity(errors): 18 | """ 19 | :param errors: 20 | :type errors: dict of (string, list or str) 21 | { 22 | "artist": [ 23 | "Should be existing artist." 24 | ], 25 | "isrc": [ 26 | "Missing data for required field." 27 | ] 28 | } 29 | :return: 30 | """ 31 | pair = list(errors.items())[0] 32 | message = 'Key {}. {}'.format(pair[0], ' '.join(pair[1])) 33 | return error_message(422, message) 34 | -------------------------------------------------------------------------------- /app/api/resources.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | from flask import request, jsonify 3 | 4 | from .errors import error_message 5 | 6 | api = Blueprint('api', __name__) 7 | 8 | 9 | @api.app_errorhandler(404) 10 | def handle404(error=None): 11 | return error_message(404, 'Not found url {}'.format(request.url)) 12 | 13 | 14 | @api.app_errorhandler(405) 15 | def handle405(error=None): 16 | return error_message(405, 'Method not supported') 17 | 18 | 19 | @api.app_errorhandler(500) 20 | def handle500(error=None): 21 | return error_message(500, 'Something went wrong') 22 | 23 | 24 | @api.route('/healthz', methods=('HEAD', 'GET')) 25 | def handle_healthcheck(): 26 | return 'ok' 27 | 28 | 29 | @api.route('/artists', methods=('GET', 'POST')) 30 | def handle_artists(): 31 | """ 32 | handle_artists handles /artists route 33 | returns list of artists 34 | """ 35 | if request.method == 'POST': 36 | return 'ok' 37 | 38 | return jsonify([{'name': 'enya'}]) 39 | -------------------------------------------------------------------------------- /bin/bundlelambda: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # creates the zip bundle for AWS lambda 4 | # 5 | 6 | set -e 7 | 8 | DIR_CUR="$PWD" 9 | ARCHIVE_TMP="/tmp/lambda-bundle-tmp.zip" 10 | 11 | addToZip() { 12 | local exclude_packages="setuptools pip easy_install" 13 | zip -r9 "$ARCHIVE_TMP" \ 14 | --exclude ./*.pyc \ 15 | --exclude "$exclude_packages" \ 16 | -- "${@}" 17 | } 18 | 19 | setUp() { 20 | if [[ -z $1 ]]; then 21 | echo "FAIL: missing \$1 argument: output lambda zip filepath" 22 | exit 1 23 | fi 24 | distDir=$(dirname "$1") 25 | if [[ ! -d "$distDir" ]]; then 26 | echo "creating dir: $distDir" 27 | mkdir "$distDir" 28 | fi 29 | rm -f "$ARCHIVE_TMP" 30 | rm -f "$1" 31 | } 32 | 33 | addDependenciesToZip() { 34 | packages_dir=() 35 | packages_dir+=($(python -c "from distutils.sysconfig import get_python_lib; print(get_python_lib())")) 36 | env_packages=$(python -c "from distutils.sysconfig import get_python_lib; print(get_python_lib(plat_specific=1))") 37 | if [[ "$env_packages" != "${packages_dir[0]}" ]]; then 38 | packages_dir+=($env_packages) 39 | fi 40 | 41 | for (( i=0; i<${#packages_dir[@]}; i++ )); do 42 | [[ -d "${packages_dir[$i]}" ]] && cd "${packages_dir[$i]}" && addToZip -- * 43 | done 44 | cd "$DIR_CUR" || exit 1 45 | } 46 | 47 | run() { 48 | if [[ -z $1 ]]; then 49 | echo "FAIL: missing \$1 argument: output lambda zip filepath" 50 | exit 1 51 | fi 52 | make deps 53 | echo "[OK] finished installing dependencies" 54 | setUp "$1" 55 | addDependenciesToZip 56 | addToZip app config ./*.py 57 | mv "$ARCHIVE_TMP" "$1" 58 | ls -lh "$1" 59 | } 60 | 61 | 62 | # shellcheck disable=SC1091 63 | . ./bin/lib/activate-env.sh 64 | sourceEnv 65 | 66 | if [[ $(uname) == 'Linux' ]]; then 67 | activatePythonEnv 68 | run "$1" 69 | else 70 | echo "[FAIL]: Wrong platform - could not build lambda zip. Must be built on Linux architecture" 71 | exit 1 72 | fi 73 | -------------------------------------------------------------------------------- /bin/deploy: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | # shellcheck disable=SC1091 5 | . ./bin/lib/activate-env.sh 6 | sourceEnv 7 | 8 | if [[ $SKIP_BUNDLE -ne 1 ]]; then 9 | ./bin/bundlelambda "$LAMBDA_ZIP" 10 | fi 11 | 12 | ./bin/deployterraform 13 | 14 | ./bin/pollapi 15 | -------------------------------------------------------------------------------- /bin/deployterraform: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | CURRENT_DIR=$PWD 5 | DEPLOY_OUTPUT="$CURRENT_DIR/.env.deploy" 6 | # shellcheck disable=SC1091 7 | . ./bin/lib/activate-env.sh 8 | sourceEnv 9 | 10 | cd "$CURRENT_DIR/terraform" || exit 1 11 | if [[ ! -d .terraform ]]; then 12 | terraform init 13 | fi 14 | if ! terraform workspace list 2>&1 | grep -qi "$ENVIRONMENT"; then 15 | terraform workspace new "$ENVIRONMENT" 16 | fi 17 | terraform workspace select "$ENVIRONMENT" 18 | terraform get 19 | 20 | terraform plan \ 21 | -var "lambda_zip_path=$LAMBDA_ZIP_PATH" \ 22 | -var "region=$REGION" \ 23 | -var "account_id=$ACCOUNT_ID_AWS" \ 24 | -var "lambda_function_name=$LAMBDA_FUNCTION_NAME" \ 25 | -var "db_instance_type=$DB_INSTANCE_TYPE" \ 26 | -var "db_name=$DB_NAME" \ 27 | -var "db_user=$DB_USER" \ 28 | -var "db_pass=$DB_PASS" \ 29 | -var "trusted_cidrs=$TRUSTED_CIDRS" 30 | 31 | terraform apply \ 32 | -auto-approve \ 33 | -var "lambda_zip_path=$LAMBDA_ZIP_PATH" \ 34 | -var "region=$REGION" \ 35 | -var "account_id=$ACCOUNT_ID_AWS" \ 36 | -var "db_instance_type=$DB_INSTANCE_TYPE" \ 37 | -var "db_name=$DB_NAME" \ 38 | -var "db_user=$DB_USER" \ 39 | -var "db_pass=$DB_PASS" \ 40 | -var "trusted_cidrs=$TRUSTED_CIDRS" 41 | 42 | echo "API_URL=$(terraform output api_url)" >> "$DEPLOY_OUTPUT" 43 | cd "$CURRENT_DIR" || exit 1 44 | 45 | -------------------------------------------------------------------------------- /bin/destroy: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | CURRENT_DIR=$PWD 5 | # shellcheck disable=SC1091 6 | . ./bin/lib/activate-env.sh 7 | sourceEnv 8 | 9 | # run infrastructure update 10 | cd terraform || exit 1 11 | if [[ ! -d .terraform ]]; then 12 | terraform init 13 | fi 14 | if ! terraform workspace list 2>&1 | grep -qi "$ENVIRONMENT"; then 15 | terraform workspace new "$ENVIRONMENT" 16 | fi 17 | terraform workspace select "$ENVIRONMENT" 18 | 19 | terraform plan -destroy \ 20 | -var "lambda_zip_path=$LAMBDA_ZIP_PATH" \ 21 | -var "region=$REGION" \ 22 | -var "account_id=$ACCOUNT_ID_AWS" \ 23 | -var "lambda_function_name=$LAMBDA_FUNCTION_NAME" \ 24 | -var "db_instance_type=$DB_INSTANCE_TYPE" \ 25 | -var "db_name=$DB_NAME" \ 26 | -var "db_user=$DB_USER" \ 27 | -var "db_pass=$DB_PASS" \ 28 | -var "trusted_cidrs=$TRUSTED_CIDRS" 29 | 30 | terraform destroy \ 31 | -auto-approve \ 32 | -var "lambda_zip_path=$LAMBDA_ZIP_PATH" \ 33 | -var "region=$REGION" \ 34 | -var "account_id=$ACCOUNT_ID_AWS" \ 35 | -var "lambda_function_name=$LAMBDA_FUNCTION_NAME" \ 36 | -var "db_instance_type=$DB_INSTANCE_TYPE" \ 37 | -var "db_name=$DB_NAME" \ 38 | -var "db_user=$DB_USER" \ 39 | -var "db_pass=$DB_PASS" \ 40 | -var "trusted_cidrs=$TRUSTED_CIDRS" 41 | 42 | cd "$CURRENT_DIR" || exit 1 43 | -------------------------------------------------------------------------------- /bin/lib/activate-env.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | sourceEnv() { 4 | if [[ -f .env ]]; then 5 | # shellcheck disable=SC1091 6 | . .env 7 | fi 8 | } 9 | 10 | sourceDeployTerraform() { 11 | if [[ -f .env.deploy ]]; then 12 | # shellcheck disable=SC1091 13 | . .env.deploy 14 | fi 15 | } 16 | 17 | activatePythonEnv() { 18 | if [[ ! -d env ]]; then 19 | make env 20 | fi 21 | 22 | if [[ $VIRTUAL_ENV != $PWD/env ]]; then 23 | # shellcheck disable=SC1091 24 | . env/bin/activate 25 | fi 26 | } 27 | -------------------------------------------------------------------------------- /bin/output: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | CURRENT_DIR=$PWD 5 | # shellcheck disable=SC1091 6 | . ./bin/lib/activate-env.sh 7 | sourceEnv 8 | 9 | cd "$CURRENT_DIR/terraform" || exit 1 10 | if [[ ! -d .terraform ]]; then 11 | terraform init 12 | fi 13 | if ! terraform workspace list 2>&1 | grep -qi "$ENVIRONMENT"; then 14 | terraform workspace new "$ENVIRONMENT" 15 | fi 16 | terraform workspace select "$ENVIRONMENT" 17 | 18 | terraform plan \ 19 | -var "lambda_zip_path=$LAMBDA_ZIP_PATH" \ 20 | -var "region=$REGION" \ 21 | -var "account_id=$ACCOUNT_ID_AWS" \ 22 | -var "lambda_function_name=$LAMBDA_FUNCTION_NAME" \ 23 | -var "db_instance_type=$DB_INSTANCE_TYPE" \ 24 | -var "db_name=$DB_NAME" \ 25 | -var "db_user=$DB_USER" \ 26 | -var "db_pass=$DB_PASS" \ 27 | -var "trusted_cidrs=$TRUSTED_CIDRS" 28 | 29 | terraform output 30 | 31 | cd "$CURRENT_DIR" || exit 1 32 | -------------------------------------------------------------------------------- /bin/pollapi: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # shellcheck disable=SC1091 4 | . ./bin/lib/activate-env.sh 5 | sourceEnv 6 | sourceDeployTerraform 7 | 8 | MAX_RETRIES=60 9 | RETRIES=1 10 | 11 | set +e 12 | echo "Testing API URL: $API_URL/healthz" 13 | until wget -qO- --timeout 5 -- "$API_URL/healthz" >/dev/null 2>&1; do 14 | if [[ $RETRIES -eq $MAX_RETRIES ]]; then 15 | echo "[FAIL] API URL unreachable. Max retry limit of $RETRIES reached." 16 | exit 1 17 | fi 18 | ((RETRIES++)) 19 | sleep 1 20 | done 21 | 22 | set -e 23 | if [[ $RETRIES -eq $MAX_RETRIES ]]; then 24 | echo "[FAIL] Deployment unsuccessful - API not responding - max retries reached" 25 | exit 1 26 | else 27 | echo "[OK] Deployment successful" 28 | fi 29 | -------------------------------------------------------------------------------- /config/.gitignore: -------------------------------------------------------------------------------- 1 | development.py 2 | testing.py 3 | -------------------------------------------------------------------------------- /config/default.py: -------------------------------------------------------------------------------- 1 | DEBUG = False 2 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | app: 4 | build: 5 | context: . 6 | dockerfile: Dockerfile 7 | volumes: 8 | - .:/usr/src/app/ 9 | working_dir: /usr/src/app/ 10 | entrypoint: ["make", 'server-docker'] 11 | environment: 12 | - APP_CONFIG_FILE=../config/development.py 13 | ports: 14 | - "5000:5000" 15 | -------------------------------------------------------------------------------- /requirements-to-freeze.txt: -------------------------------------------------------------------------------- 1 | autopep8 2 | flask 3 | # for python <= 3.5 use flask-lambda pacakge 4 | flask-lambda-python36 5 | http-prompt 6 | pep8 7 | pre-commit 8 | psycopg2 9 | pytest 10 | pytest-cov 11 | requests 12 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aspy.yaml==1.1.0 2 | attrs==17.4.0 3 | autopep8==1.3.5 4 | cached-property==1.4.2 5 | certifi==2018.1.18 6 | cfgv==1.0.0 7 | chardet==3.0.4 8 | click==6.7 9 | coverage==4.5.1 10 | Flask==1.0 11 | flask-lambda-python36==0.1.0 12 | http-prompt==0.11.2 13 | httpie==3.1.0 14 | identify==1.0.10 15 | idna==2.6 16 | itsdangerous==0.24 17 | Jinja2==2.10 18 | MarkupSafe==1.0 19 | more-itertools==4.1.0 20 | nodeenv==1.3.0 21 | parsimonious==0.8.0 22 | pep8==1.7.1 23 | pluggy==0.6.0 24 | pre-commit==1.8.2 25 | prompt-toolkit==1.0.15 26 | psycopg2==2.7.4 27 | py==1.5.3 28 | pycodestyle==2.4.0 29 | Pygments==2.2.0 30 | pytest==3.5.0 31 | pytest-cov==2.5.1 32 | PyYAML==5.1 33 | requests==2.20.0 34 | six==1.11.0 35 | urllib3==1.25.6 36 | virtualenv==15.2.0 37 | wcwidth==0.1.7 38 | Werkzeug==0.15.3 39 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | from app import create_app 3 | 4 | 5 | http_server = create_app(Flask) 6 | -------------------------------------------------------------------------------- /run_lambda.py: -------------------------------------------------------------------------------- 1 | # for python <= 3.5 update your requirements-to-freeze.txt 2 | # to use the flask-lambda pip pacakage 3 | from flask_lambda import FlaskLambda 4 | from app import create_app 5 | 6 | 7 | http_server = create_app(FlaskLambda) 8 | -------------------------------------------------------------------------------- /template.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Transform: 'AWS::Serverless-2016-10-31' 3 | Description: 'Boilerplate Python 3.6 Flask App.' 4 | Resources: 5 | FlaskAPI: 6 | Type: 'AWS::Serverless::Function' 7 | Properties: 8 | CodeUri: s3:///flask-app.zip 9 | Handler: run_lambda.http_server 10 | Runtime: python3.6 11 | Events: 12 | HTTPRequest: 13 | Type: Api 14 | Properties: 15 | Path: /{proxy+} 16 | Method: ANY 17 | -------------------------------------------------------------------------------- /terraform/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | *.zip 3 | -------------------------------------------------------------------------------- /terraform/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | backend "s3" { 3 | bucket = "python-serverless-api-remote-state" 4 | key = "terraform.tfstate" 5 | region = "eu-west-2" 6 | } 7 | } 8 | 9 | module "lambda_api_gateway" { 10 | source = "git@github.com:techjacker/terraform-aws-lambda-api-gateway" 11 | 12 | # tags 13 | project = "${var.project}" 14 | service = "${var.service}" 15 | owner = "${var.owner}" 16 | costcenter = "${var.costcenter}" 17 | 18 | # vpc 19 | vpc_cidr = "${var.vpc_cidr}" 20 | public_subnets_cidr = "${var.public_subnets_cidr}" 21 | private_subnets_cidr = "${var.private_subnets_cidr}" 22 | nat_cidr = "${var.nat_cidr}" 23 | igw_cidr = "${var.igw_cidr}" 24 | azs = "${var.azs}" 25 | 26 | # lambda 27 | lambda_zip_path = "${var.lambda_zip_path}" 28 | lambda_handler = "${var.lambda_handler}" 29 | lambda_runtime = "${var.lambda_runtime}" 30 | lambda_function_name = "${var.lambda_function_name}" 31 | 32 | # API gateway 33 | region = "${var.region}" 34 | account_id = "${var.account_id}" 35 | } 36 | -------------------------------------------------------------------------------- /terraform/outputs.tf: -------------------------------------------------------------------------------- 1 | # output "db_url" { 2 | # value = "${module.rds_instance.url}" 3 | # } 4 | 5 | output "api_url" { 6 | value = "${module.lambda_api_gateway.api_url}" 7 | } 8 | 9 | output "lambda_zip" { 10 | value = "${module.lambda_api_gateway.lambda_zip}" 11 | } 12 | -------------------------------------------------------------------------------- /terraform/variables.tf: -------------------------------------------------------------------------------- 1 | #################### 2 | # Tags 3 | #################### 4 | variable "project" { 5 | default = "python-serverless-api" 6 | } 7 | 8 | variable "owner" { 9 | default = "mail@andrewgriffithsonline.com" 10 | } 11 | 12 | variable "costcenter" { 13 | default = "AGO" 14 | } 15 | 16 | variable "service" { 17 | default = "ago" 18 | } 19 | 20 | #################### 21 | # VPC 22 | #################### 23 | variable vpc_cidr { 24 | default = "10.0.0.0/16" 25 | } 26 | 27 | variable public_subnets_cidr { 28 | default = ["10.0.1.0/24", "10.0.2.0/24"] 29 | } 30 | 31 | variable private_subnets_cidr { 32 | default = ["10.0.3.0/24", "10.0.4.0/24"] 33 | } 34 | 35 | variable nat_cidr { 36 | default = ["10.0.5.0/24", "10.0.6.0/24"] 37 | } 38 | 39 | variable igw_cidr { 40 | default = "10.0.8.0/24" 41 | } 42 | 43 | variable azs { 44 | default = ["eu-west-2a", "eu-west-2b"] 45 | } 46 | 47 | #################### 48 | # lambda 49 | #################### 50 | variable "lambda_runtime" { 51 | default = "python3.6" 52 | } 53 | 54 | variable "lambda_zip_path" {} 55 | 56 | variable "lambda_function_name" { 57 | default = "HttpServer" 58 | } 59 | 60 | variable "lambda_handler" { 61 | default = "run_lambda.http_server" 62 | } 63 | 64 | #################### 65 | # API Gateway 66 | #################### 67 | variable "region" { 68 | default = "eu-west-2" 69 | } 70 | 71 | variable "account_id" {} 72 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/techjacker/python-serverless-api/3e8dcc336bfbd8fb7dc0bee5acd0eeb4e68b1d5d/tests/__init__.py -------------------------------------------------------------------------------- /tests/apis/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/techjacker/python-serverless-api/3e8dcc336bfbd8fb7dc0bee5acd0eeb4e68b1d5d/tests/apis/__init__.py -------------------------------------------------------------------------------- /tests/apis/conftest.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import socket 3 | import time 4 | 5 | import pytest 6 | 7 | from flask import Flask 8 | from app import create_app 9 | 10 | 11 | def get_open_port(): 12 | """ Find free port on a local system """ 13 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 14 | s.bind(('', 0)) 15 | port = s.getsockname()[1] 16 | s.close() 17 | return port 18 | 19 | 20 | def wait_until(predicate, timeout=5, interval=0.05, *args, **kwargs): 21 | mustend = time.time() + timeout 22 | while time.time() < mustend: 23 | if predicate(*args, **kwargs): 24 | return True 25 | time.sleep(interval) 26 | return False 27 | 28 | 29 | @pytest.fixture 30 | def server(): 31 | http_server = create_app(Flask) 32 | 33 | port = get_open_port() 34 | http_server.url = 'http://localhost:{}'.format(port) 35 | 36 | def start(): 37 | print('start server') 38 | http_server.run(port=port) 39 | 40 | p = threading.Thread(target=start) 41 | p.daemon = True 42 | p.start() 43 | 44 | def check(): 45 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 46 | try: 47 | s.connect(('localhost', port)) 48 | return True 49 | except Exception: 50 | return False 51 | finally: 52 | s.close() 53 | 54 | rc = wait_until(check) 55 | assert rc, 'failed to start service' 56 | 57 | yield http_server 58 | 59 | p.join(timeout=0.5) 60 | -------------------------------------------------------------------------------- /tests/apis/test_artists.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | 4 | def test_post(server): 5 | with server.app_context(): 6 | r = requests.post(server.url + '/artists', data={ 7 | 'name': 'a' 8 | }) 9 | assert r.status_code == 200 10 | assert r.text == 'ok' 11 | 12 | 13 | def test_get_many(server): 14 | r = requests.get(server.url + '/artists') 15 | assert r.status_code == 200 16 | items = r.json() 17 | assert items[0]['name'] == 'enya' 18 | -------------------------------------------------------------------------------- /tests/apis/test_artists.py.bak: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlencode 2 | 3 | import pytest 4 | import requests 5 | 6 | from app.models import Artist, db 7 | 8 | 9 | def test_post(server): 10 | with server.app_context(): 11 | assert Artist.query.count() == 0 12 | 13 | r = requests.post(server.url + '/artists', data={ 14 | 'name': 'a' 15 | }) 16 | 17 | assert r.status_code == 200 18 | assert r.json()['name'] == 'a' 19 | 20 | with server.app_context(): 21 | assert Artist.query.count() == 1 22 | artist = Artist.query.first() 23 | assert artist.name == 'a' 24 | 25 | 26 | @pytest.mark.parametrize('data, status_code', [ 27 | ({}, 422), 28 | ({'name': ''}, 422), 29 | ]) 30 | def test_post_errors(server, data, status_code): 31 | with server.app_context(): 32 | assert Artist.query.count() == 0 33 | 34 | r = requests.post(server.url + '/artists', data=data) 35 | 36 | assert r.status_code == status_code 37 | assert Artist.query.count() == 0 38 | 39 | 40 | def test_post_errors_on_unique(server): 41 | with server.app_context(): 42 | artist = Artist('a') 43 | db.session.add(artist) 44 | db.session.commit() 45 | 46 | assert Artist.query.count() == 1 47 | 48 | r = requests.post(server.url + '/artists', data={'name': 'a'}) 49 | 50 | assert r.status_code == 422 51 | assert Artist.query.count() == 1 52 | 53 | 54 | def test_put(server): 55 | with server.app_context(): 56 | artist = Artist('a') 57 | db.session.add(artist) 58 | db.session.commit() 59 | 60 | assert Artist.query.count() == 1 61 | 62 | r = requests.put(server.url + '/artists/{}'.format(artist.id), data={ 63 | 'name': 'b' 64 | }) 65 | assert r.status_code == 200 66 | assert r.json()['name'] == 'b' 67 | 68 | with server.app_context(): 69 | assert Artist.query.count() == 1 70 | artist = Artist.query.get(artist.id) 71 | assert artist.name == 'b' 72 | 73 | 74 | def test_put_same_name(server): 75 | with server.app_context(): 76 | artist = Artist('b') 77 | db.session.add(artist) 78 | db.session.commit() 79 | 80 | assert Artist.query.count() == 1 81 | 82 | r = requests.put(server.url + '/artists/{}'.format(artist.id), data={ 83 | 'name': 'b' 84 | }) 85 | assert r.status_code == 200 86 | assert r.json()['name'] == 'b' 87 | 88 | with server.app_context(): 89 | assert Artist.query.count() == 1 90 | artist = Artist.query.get(artist.id) 91 | assert artist.name == 'b' 92 | 93 | 94 | def test_put_errors(server): 95 | with server.app_context(): 96 | artist = Artist('a') 97 | db.session.add(artist) 98 | artist2 = Artist('b') 99 | db.session.add(artist2) 100 | db.session.commit() 101 | 102 | assert Artist.query.count() == 2 103 | 104 | r = requests.put(server.url + '/artists/{}'.format(artist.id), data={ 105 | 'name': 'b' 106 | }) 107 | assert r.status_code == 422 108 | 109 | 110 | def test_delete(server): 111 | with server.app_context(): 112 | artist = Artist('a') 113 | db.session.add(artist) 114 | db.session.commit() 115 | 116 | assert Artist.query.count() == 1 117 | 118 | r = requests.delete(server.url + '/artists/{}'.format(artist.id)) 119 | assert r.status_code == 204 120 | 121 | with server.app_context(): 122 | assert Artist.query.count() == 0 123 | 124 | 125 | def test_get_one(server): 126 | with server.app_context(): 127 | artist = Artist('a') 128 | db.session.add(artist) 129 | db.session.commit() 130 | 131 | assert Artist.query.count() == 1 132 | 133 | r = requests.get(server.url + '/artists/{}'.format(artist.id)) 134 | assert r.status_code == 200 135 | assert r.json()['name'] == artist.name 136 | 137 | 138 | @pytest.mark.parametrize('arguments, names', [ 139 | ({}, ['a', 'b', 'c']), 140 | ({'page': 1, 'per_page': 2}, ['a', 'b']), 141 | ({'page': 2, 'per_page': 2}, ['c']), 142 | ]) 143 | def test_get_many(server, arguments, names): 144 | with server.app_context(): 145 | for name in ('a', 'b', 'c'): 146 | db.session.add(Artist(name)) 147 | db.session.commit() 148 | 149 | r = requests.get(server.url + '/artists?{}'.format(urlencode(arguments))) 150 | assert r.status_code == 200 151 | items = r.json() 152 | assert len(items) == len(names) 153 | assert list(map(lambda x: x['name'], items)) == names 154 | -------------------------------------------------------------------------------- /tests/apis/test_errors.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | 4 | def test_404(server): 5 | r = requests.get(server.url + '/not-found') 6 | assert r.status_code == 404 7 | data = r.json() 8 | assert 'error' in data 9 | assert 'Not found' in data['error']['message'] 10 | 11 | 12 | def test_405(server): 13 | r = requests.delete(server.url + '/artists') 14 | assert r.status_code == 405 15 | data = r.json() 16 | assert 'error' in data 17 | assert 'Method not supported' in data['error']['message'] 18 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pytest 3 | 4 | 5 | @pytest.fixture(autouse=True) 6 | def config_setting(): 7 | """An application for the tests.""" 8 | fallback_config = os.path.abspath( 9 | os.path.join( 10 | os.path.dirname(__file__), '../config/testing.py' 11 | ) 12 | ) 13 | os.environ['APP_CONFIG_FILE'] = \ 14 | os.environ.get('TEST_APP_CONFIG_FILE', fallback_config) 15 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # http://pytest.org/latest/customize.html 2 | # content of pytest.ini (or tox.ini or setup.cfg) 3 | [pytest] 4 | addopts = -x --capture=no -v --junitxml=junit.xml 5 | testpaths = tests 6 | norecursedirs = fixtures 7 | --------------------------------------------------------------------------------