├── .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 |
--------------------------------------------------------------------------------