├── {{cookiecutter.project_name}} ├── tests │ ├── __init__.py │ ├── factories.py │ ├── test_auth.py │ ├── test_celery.py │ ├── conftest.py │ └── test_user.py ├── {{cookiecutter.app_name}} │ ├── __init__.py │ ├── tasks │ │ ├── __init__.py │ │ └── example.py │ ├── commons │ │ ├── __init__.py │ │ ├── templates │ │ │ ├── redoc.j2 │ │ │ └── swagger.j2 │ │ ├── pagination.py │ │ └── apispec.py │ ├── wsgi.py │ ├── api │ │ ├── __init__.py │ │ ├── schemas │ │ │ ├── __init__.py │ │ │ └── user.py │ │ ├── resources │ │ │ ├── __init__.py │ │ │ └── user.py │ │ └── views.py │ ├── auth │ │ ├── __init__.py │ │ ├── helpers.py │ │ └── views.py │ ├── celery_app.py │ ├── models │ │ ├── __init__.py │ │ ├── user.py │ │ └── blocklist.py │ ├── config.py │ ├── manage.py │ ├── extensions.py │ └── app.py ├── .dockerignore ├── .testenv ├── .flaskenv ├── requirements.txt ├── cookiecutter-options.yml ├── setup.py ├── Dockerfile ├── tox.ini ├── .gitignore ├── Makefile └── docker-compose.yml ├── cookiecutter.json ├── .travis.yml ├── hooks └── post_gen_project.py ├── LICENSE ├── .gitignore └── README.md /{{cookiecutter.project_name}}/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/{{cookiecutter.app_name}}/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/{{cookiecutter.app_name}}/tasks/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/{{cookiecutter.app_name}}/commons/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/.dockerignore: -------------------------------------------------------------------------------- 1 | .flaskenv 2 | Dockerfile 3 | Makefile 4 | docker-compose.yml 5 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/{{cookiecutter.app_name}}/wsgi.py: -------------------------------------------------------------------------------- 1 | from {{cookiecutter.app_name}}.app import create_app 2 | 3 | app = create_app() 4 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/{{cookiecutter.app_name}}/api/__init__.py: -------------------------------------------------------------------------------- 1 | from {{cookiecutter.app_name}}.api import views 2 | 3 | __all__ = ["views"] 4 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/{{cookiecutter.app_name}}/auth/__init__.py: -------------------------------------------------------------------------------- 1 | from {{cookiecutter.app_name}}.auth import views 2 | 3 | __all__ = ["views"] 4 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/{{cookiecutter.app_name}}/api/schemas/__init__.py: -------------------------------------------------------------------------------- 1 | from {{cookiecutter.app_name}}.api.schemas.user import UserSchema 2 | 3 | 4 | __all__ = ["UserSchema"] 5 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/{{cookiecutter.app_name}}/tasks/example.py: -------------------------------------------------------------------------------- 1 | from {{cookiecutter.app_name}}.extensions import celery 2 | 3 | 4 | @celery.task 5 | def dummy_task(): 6 | return "OK" 7 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/{{cookiecutter.app_name}}/api/resources/__init__.py: -------------------------------------------------------------------------------- 1 | from {{cookiecutter.app_name}}.api.resources.user import UserResource, UserList 2 | 3 | 4 | __all__ = ["UserResource", "UserList"] 5 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/{{cookiecutter.app_name}}/celery_app.py: -------------------------------------------------------------------------------- 1 | from {{cookiecutter.app_name}}.app import init_celery 2 | 3 | app = init_celery() 4 | app.conf.imports = app.conf.imports + ("{{cookiecutter.app_name}}.tasks.example",) 5 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/.testenv: -------------------------------------------------------------------------------- 1 | SECRET_KEY=testing 2 | DATABASE_URI=sqlite:///:memory: 3 | {%- if cookiecutter.use_celery == "yes" %} 4 | CELERY_BROKER_URL=amqp://guest:guest@localhost/ 5 | CELERY_RESULT_BACKEND_URL=rpc:// 6 | {%- endif %} 7 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/{{cookiecutter.app_name}}/models/__init__.py: -------------------------------------------------------------------------------- 1 | from {{cookiecutter.app_name}}.models.user import User 2 | from {{cookiecutter.app_name}}.models.blocklist import TokenBlocklist 3 | 4 | 5 | __all__ = ["User", "TokenBlocklist"] 6 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/.flaskenv: -------------------------------------------------------------------------------- 1 | FLASK_ENV=development 2 | FLASK_APP={{cookiecutter.app_name}}.app:create_app 3 | SECRET_KEY=changeme 4 | DATABASE_URI=sqlite:///{{cookiecutter.app_name}}.db 5 | {%- if cookiecutter.use_celery == "yes" %} 6 | CELERY_BROKER_URL=amqp://guest:guest@localhost/ 7 | CELERY_RESULT_BACKEND_URL=rpc:// 8 | {%- endif %} 9 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/tests/factories.py: -------------------------------------------------------------------------------- 1 | import factory 2 | from {{cookiecutter.app_name}}.models import User 3 | 4 | 5 | class UserFactory(factory.Factory): 6 | 7 | username = factory.Sequence(lambda n: "user%d" % n) 8 | email = factory.Sequence(lambda n: "user%d@mail.com" % n) 9 | password = "mypwd" 10 | 11 | class Meta: 12 | model = User 13 | -------------------------------------------------------------------------------- /cookiecutter.json: -------------------------------------------------------------------------------- 1 | { 2 | "project_name": "restful_api", 3 | "app_name": "myapi", 4 | "python_version": ["3.8", "3.7", "3.6"], 5 | "tox_python_env": "py{{ cookiecutter.python_version|replace('.','') }}", 6 | "use_celery": ["no", "yes"], 7 | "admin_user_username": "admin", 8 | "admin_user_email": "admin@mail.com", 9 | "admin_user_password": "admin", 10 | "wsgi_server": ["none", "uwsgi", "gunicorn"] 11 | } 12 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/{{cookiecutter.app_name}}/api/schemas/user.py: -------------------------------------------------------------------------------- 1 | from {{cookiecutter.app_name}}.models import User 2 | from {{cookiecutter.app_name}}.extensions import ma, db 3 | 4 | 5 | class UserSchema(ma.SQLAlchemyAutoSchema): 6 | 7 | id = ma.Int(dump_only=True) 8 | password = ma.String(load_only=True, required=True) 9 | 10 | class Meta: 11 | model = User 12 | sqla_session = db.session 13 | load_instance = True 14 | exclude = ("_password",) 15 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/requirements.txt: -------------------------------------------------------------------------------- 1 | marshmallow>=3 2 | flask 3 | flask-restful 4 | flask-migrate 5 | flask-sqlalchemy 6 | flask-marshmallow 7 | flask-jwt-extended 8 | marshmallow-sqlalchemy 9 | python-dotenv 10 | passlib 11 | apispec[yaml] 12 | apispec-webframeworks 13 | tox 14 | {%- if cookiecutter.use_celery == "yes" %} 15 | celery[redis]>=5.0.0 16 | {%- endif %} 17 | {%- if cookiecutter.wsgi_server == "uwsgi" %} 18 | uwsgi 19 | {%- endif %} 20 | {%- if cookiecutter.wsgi_server == "gunicorn" %} 21 | gunicorn 22 | {%- endif %} 23 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/cookiecutter-options.yml: -------------------------------------------------------------------------------- 1 | default_context: 2 | project_name: "{{ cookiecutter.project_name }}" 3 | app_name: "{{ cookiecutter.app_name }}" 4 | python_version: "{{ cookiecutter.python_version }}" 5 | admin_user_username: "{{ cookiecutter.admin_user_username }}" 6 | admin_user_email: "{{ cookiecutter.admin_user_email }}" 7 | admin_user_password: "{{ cookiecutter.admin_user_password }}" 8 | use_celery: "{{ cookiecutter.use_celery }}" 9 | wsgi_server: "{{ cookiecutter.wsgi_server }}" 10 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/{{cookiecutter.app_name}}/config.py: -------------------------------------------------------------------------------- 1 | """Default configuration 2 | 3 | Use env var to override 4 | """ 5 | import os 6 | 7 | ENV = os.getenv("FLASK_ENV") 8 | DEBUG = ENV == "development" 9 | SECRET_KEY = os.getenv("SECRET_KEY") 10 | 11 | SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URI") 12 | SQLALCHEMY_TRACK_MODIFICATIONS = False 13 | 14 | {%- if cookiecutter.use_celery == "yes" %} 15 | CELERY = { 16 | "broker_url": os.getenv("CELERY_BROKER_URL"), 17 | "result_backend": os.getenv("CELERY_RESULT_BACKEND_URL"), 18 | } 19 | {%- endif %} 20 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/tests/test_auth.py: -------------------------------------------------------------------------------- 1 | def test_revoke_access_token(client, admin_headers): 2 | resp = client.delete("/auth/revoke_access", headers=admin_headers) 3 | assert resp.status_code == 200 4 | 5 | resp = client.get("/api/v1/users", headers=admin_headers) 6 | assert resp.status_code == 401 7 | 8 | 9 | def test_revoke_refresh_token(client, admin_refresh_headers): 10 | resp = client.delete("/auth/revoke_refresh", headers=admin_refresh_headers) 11 | assert resp.status_code == 200 12 | 13 | resp = client.post("/auth/refresh", headers=admin_refresh_headers) 14 | assert resp.status_code == 401 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | matrix: 3 | include: 4 | - name: "python 3.6" 5 | python: 3.6 6 | env: TOXENV=py36 7 | python: 3.6 8 | - name: "python 3.7" 9 | python: 3.7 10 | env: TOXENV=py37 11 | python: 3.7 12 | - name: "python 3.8" 13 | env: TOXENV=py38 14 | python: 3.8 15 | 16 | services: 17 | - rabbitmq 18 | 19 | before_install: 20 | - "sudo apt-get update" 21 | - "sudo apt-get install rabbitmq-server" 22 | - "pip3 install --upgrade pip cookiecutter" 23 | - "cookiecutter . --no-input use_celery=yes" 24 | install: "pip3 install tox" 25 | script: "tox -c restful_api/tox.ini" 26 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages 2 | from setuptools import setup 3 | 4 | __version__ = "0.1" 5 | 6 | setup( 7 | name="{{cookiecutter.app_name}}", 8 | version=__version__, 9 | packages=find_packages(exclude=["tests"]), 10 | install_requires=[ 11 | "flask", 12 | "flask-sqlalchemy", 13 | "flask-restful", 14 | "flask-migrate", 15 | "flask-jwt-extended", 16 | "flask-marshmallow", 17 | "marshmallow-sqlalchemy", 18 | "python-dotenv", 19 | "passlib", 20 | "apispec[yaml]", 21 | "apispec-webframeworks", 22 | ] 23 | ) 24 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/{{cookiecutter.app_name}}/manage.py: -------------------------------------------------------------------------------- 1 | import click 2 | from flask.cli import with_appcontext 3 | 4 | 5 | @click.command("init") 6 | @with_appcontext 7 | def init(): 8 | """Create a new admin user""" 9 | from {{cookiecutter.app_name}}.extensions import db 10 | from {{cookiecutter.app_name}}.models import User 11 | 12 | click.echo("create user") 13 | user = User(username="{{cookiecutter.admin_user_username}}", email="{{cookiecutter.admin_user_email}}", password="{{cookiecutter.admin_user_password}}", active=True) 14 | db.session.add(user) 15 | db.session.commit() 16 | click.echo("created user admin") 17 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/tests/test_celery.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from {{cookiecutter.app_name}}.app import init_celery 4 | from {{cookiecutter.app_name}}.tasks.example import dummy_task 5 | 6 | 7 | @pytest.fixture(scope="session") 8 | def celery_session_app(celery_session_app, app): 9 | celery = init_celery(app) 10 | 11 | celery_session_app.conf = celery.conf 12 | celery_session_app.Task = celery_session_app.Task 13 | 14 | yield celery_session_app 15 | 16 | 17 | @pytest.fixture(scope="session") 18 | def celery_worker_pool(): 19 | return "solo" 20 | 21 | 22 | def test_example(celery_session_app, celery_session_worker): 23 | """Simply test our dummy task using celery""" 24 | res = dummy_task.delay() 25 | assert res.get() == "OK" 26 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/Dockerfile: -------------------------------------------------------------------------------- 1 | # This is a simple Dockerfile to use while developing 2 | # It's not suitable for production 3 | # 4 | # It allows you to run both flask and celery if you enabled it 5 | # for flask: docker run --env-file=.flaskenv image flask run 6 | # for celery: docker run --env-file=.flaskenv image celery worker -A myapi.celery_app:app 7 | # 8 | # note that celery will require a running broker and result backend 9 | FROM python:{{ cookiecutter.python_version }} 10 | 11 | RUN mkdir /code 12 | WORKDIR /code 13 | 14 | COPY requirements.txt setup.py tox.ini ./ 15 | RUN pip install -U pip 16 | RUN pip install -r requirements.txt 17 | RUN pip install -e . 18 | 19 | COPY {{ cookiecutter.app_name }} {{ cookiecutter.app_name }}/ 20 | COPY migrations migrations/ 21 | 22 | EXPOSE 5000 23 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/{{cookiecutter.app_name}}/extensions.py: -------------------------------------------------------------------------------- 1 | """Extensions registry 2 | 3 | All extensions here are used as singletons and 4 | initialized in application factory 5 | """ 6 | from flask_sqlalchemy import SQLAlchemy 7 | from passlib.context import CryptContext 8 | from flask_jwt_extended import JWTManager 9 | from flask_marshmallow import Marshmallow 10 | from flask_migrate import Migrate 11 | {%- if cookiecutter.use_celery == "yes" %} 12 | from celery import Celery 13 | {%- endif %} 14 | 15 | from {{cookiecutter.app_name}}.commons.apispec import APISpecExt 16 | 17 | 18 | db = SQLAlchemy() 19 | jwt = JWTManager() 20 | ma = Marshmallow() 21 | migrate = Migrate() 22 | apispec = APISpecExt() 23 | pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") 24 | {%- if cookiecutter.use_celery == "yes" %} 25 | celery = Celery() 26 | {%- endif %} 27 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/{{cookiecutter.app_name}}/models/user.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.hybrid import hybrid_property 2 | 3 | from {{cookiecutter.app_name}}.extensions import db, pwd_context 4 | 5 | 6 | class User(db.Model): 7 | """Basic user model""" 8 | 9 | id = db.Column(db.Integer, primary_key=True) 10 | username = db.Column(db.String(80), unique=True, nullable=False) 11 | email = db.Column(db.String(80), unique=True, nullable=False) 12 | _password = db.Column("password", db.String(255), nullable=False) 13 | active = db.Column(db.Boolean, default=True) 14 | 15 | @hybrid_property 16 | def password(self): 17 | return self._password 18 | 19 | @password.setter 20 | def password(self, value): 21 | self._password = pwd_context.hash(value) 22 | 23 | def __repr__(self): 24 | return "" % self.username 25 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/{{cookiecutter.app_name}}/commons/templates/redoc.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{cookiecutter.project_name}} - ReDoc 5 | 6 | 7 | 8 | 9 | 10 | 16 | 17 | {% raw %} 18 | 19 | 20 | {% endraw %} 21 | 22 | 23 | -------------------------------------------------------------------------------- /hooks/post_gen_project.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import shutil 4 | 5 | use_celery = '{{cookiecutter.use_celery}}' 6 | 7 | 8 | if use_celery == "no": 9 | base_path = os.getcwd() 10 | app_path = os.path.join( 11 | base_path, 12 | '{{cookiecutter.app_name}}', 13 | ) 14 | tasks_path = os.path.join(app_path, 'tasks') 15 | celery_app_path = os.path.join(app_path, 'celery_app.py') 16 | 17 | try: 18 | shutil.rmtree(tasks_path) 19 | except Exception: 20 | print("ERROR: cannot delete celery tasks path %s" % tasks_path) 21 | sys.exit(1) 22 | 23 | try: 24 | os.remove(celery_app_path) 25 | except Exception: 26 | print("ERROR: cannot delete celery application file") 27 | sys.exit(1) 28 | 29 | try: 30 | os.remove(os.path.join(base_path, "tests", "test_celery.py")) 31 | except Exception: 32 | print("ERROR: cannot delete celery tests files") 33 | sys.exit(1) 34 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/tox.ini: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | 4 | [tox] 5 | envlist = 6 | py{38,37,36} 7 | tests 8 | black 9 | 10 | [testenv] 11 | 12 | deps= 13 | flake8 14 | pytest 15 | pytest-flask 16 | pytest-runner 17 | pytest-factoryboy 18 | pytest-celery 19 | factory_boy 20 | -rrequirements.txt 21 | black 22 | setenv = 23 | DATABASE_URI = sqlite:///:memory: 24 | SECRET_KEY = testing 25 | FLASK_ENV = development 26 | {%- if cookiecutter.use_celery == "yes" %} 27 | CELERY_BROKER_URL = {env:CELERY_BROKER_URL:amqp://guest:guest@localhost/} 28 | CELERY_RESULT_BACKEND_URL = {env:CELERY_RESULT_BACKEND_URL:rpc://} 29 | {%- endif %} 30 | 31 | commands= 32 | flake8 {{cookiecutter.app_name}} 33 | black {{cookiecutter.app_name}} --check 34 | pytest tests 35 | 36 | 37 | [testenv:test] 38 | commands= 39 | pytest tests {posargs} 40 | 41 | 42 | [testenv:lint] 43 | skip_install = true 44 | commands = 45 | flake8 {{cookiecutter.app_name}} 46 | black {{cookiecutter.app_name}} --diff --check 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Emmanuel Valette 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 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/{{cookiecutter.app_name}}/api/views.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, current_app, jsonify 2 | from flask_restful import Api 3 | from marshmallow import ValidationError 4 | from {{cookiecutter.app_name}}.extensions import apispec 5 | from {{cookiecutter.app_name}}.api.resources import UserResource, UserList 6 | from {{cookiecutter.app_name}}.api.schemas import UserSchema 7 | 8 | 9 | blueprint = Blueprint("api", __name__, url_prefix="/api/v1") 10 | api = Api(blueprint) 11 | 12 | 13 | api.add_resource(UserResource, "/users/", endpoint="user_by_id") 14 | api.add_resource(UserList, "/users", endpoint="users") 15 | 16 | 17 | @blueprint.before_app_first_request 18 | def register_views(): 19 | apispec.spec.components.schema("UserSchema", schema=UserSchema) 20 | apispec.spec.path(view=UserResource, app=current_app) 21 | apispec.spec.path(view=UserList, app=current_app) 22 | 23 | 24 | @blueprint.errorhandler(ValidationError) 25 | def handle_marshmallow_error(e): 26 | """Return json error for marshmallow validation errors. 27 | 28 | This will avoid having to try/catch ValidationErrors in all endpoints, returning 29 | correct JSON response with associated HTTP 400 Status (https://tools.ietf.org/html/rfc7231#section-6.5.1) 30 | """ 31 | return jsonify(e.messages), 400 32 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/{{cookiecutter.app_name}}/commons/pagination.py: -------------------------------------------------------------------------------- 1 | """Simple helper to paginate query 2 | """ 3 | from flask import url_for, request 4 | 5 | DEFAULT_PAGE_SIZE = 50 6 | DEFAULT_PAGE_NUMBER = 1 7 | 8 | 9 | def extract_pagination(page=None, per_page=None, **request_args): 10 | page = int(page) if page is not None else DEFAULT_PAGE_NUMBER 11 | per_page = int(per_page) if per_page is not None else DEFAULT_PAGE_SIZE 12 | return page, per_page, request_args 13 | 14 | 15 | def paginate(query, schema): 16 | page, per_page, other_request_args = extract_pagination(**request.args) 17 | page_obj = query.paginate(page=page, per_page=per_page) 18 | next_ = url_for( 19 | request.endpoint, 20 | page=page_obj.next_num if page_obj.has_next else page_obj.page, 21 | per_page=per_page, 22 | **other_request_args, 23 | **request.view_args 24 | ) 25 | prev = url_for( 26 | request.endpoint, 27 | page=page_obj.prev_num if page_obj.has_prev else page_obj.page, 28 | per_page=per_page, 29 | **other_request_args, 30 | **request.view_args 31 | ) 32 | 33 | return { 34 | "total": page_obj.total, 35 | "pages": page_obj.pages, 36 | "next": next_, 37 | "prev": prev, 38 | "results": schema.dump(page_obj.items), 39 | } 40 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: init init-migration build run db-migrate test tox 2 | 3 | init: build run 4 | docker-compose exec web flask db init 5 | docker-compose exec web flask db migrate 6 | docker-compose exec web flask db upgrade 7 | docker-compose exec web flask init 8 | @echo "Init done, containers running" 9 | 10 | build: 11 | docker-compose build 12 | 13 | run: 14 | @mkdir -p db 15 | docker-compose up -d 16 | 17 | db-init: 18 | docker-compose exec web flask db init 19 | 20 | db-migrate: 21 | docker-compose exec web flask db migrate 22 | 23 | db-upgrade: 24 | docker-compose exec web flask db upgrade 25 | 26 | test: 27 | {%- if cookiecutter.use_celery == "yes"%} 28 | docker-compose stop celery # stop celery to avoid conflicts with celery tests 29 | docker-compose start rabbitmq redis # ensuring both redis and rabbitmq are started 30 | {%- endif %} 31 | docker-compose run -v $(PWD)/tests:/code/tests:ro web tox -e test 32 | {%- if cookiecutter.use_celery == "yes"%} 33 | docker-compose start celery 34 | {%- endif %} 35 | 36 | tox: 37 | {%- if cookiecutter.use_celery == "yes"%} 38 | docker-compose stop celery # stop celery to avoid conflicts with celery tests 39 | docker-compose start rabbitmq redis # ensuring both redis and rabbitmq are started 40 | {%- endif %} 41 | docker-compose run -v $(PWD)/tests:/code/tests:ro web tox -e {{ cookiecutter.tox_python_env }} 42 | {%- if cookiecutter.use_celery == "yes"%} 43 | docker-compose start celery 44 | {%- endif %} 45 | 46 | lint: 47 | docker-compose run web tox -e lint 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | 91 | # the generated project 92 | .restful_api/ 93 | 94 | # vscode stuff 95 | .vscode/ 96 | 97 | # pycharm stuff 98 | .idea/ 99 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/docker-compose.yml: -------------------------------------------------------------------------------- 1 | {%- if cookiecutter.wsgi_server == "uwsgi" -%} 2 | {% set web_command = "uwsgi --http 0.0.0.0:5000 --module " + cookiecutter.app_name + ".wsgi:app" %} 3 | {%- elif cookiecutter.wsgi_server == "gunicorn" -%} 4 | {% set web_command = "gunicorn -b 0.0.0.0:5000 " + cookiecutter.app_name + ".wsgi:app" %} 5 | {%- else -%} 6 | {% set web_command = "flask run -h 0.0.0.0" %} 7 | {%- endif -%} 8 | # WARNING: this file is not suitable for production, please use with caution 9 | version: '3' 10 | 11 | services: 12 | web: 13 | image: {{cookiecutter.app_name}} 14 | build: . 15 | command: {{web_command}} 16 | env_file: 17 | - ./.flaskenv 18 | environment: 19 | - DATABASE_URI=sqlite:////db/{{cookiecutter.app_name}}.db 20 | {%- if cookiecutter.use_celery == "yes"%} 21 | - CELERY_BROKER_URL=amqp://guest:guest@rabbitmq 22 | - CELERY_RESULT_BACKEND_URL=redis://redis 23 | {%- endif %} 24 | volumes: 25 | - ./{{cookiecutter.app_name}}:/code/{{cookiecutter.app_name}} 26 | - ./db/:/db/ 27 | ports: 28 | - "5000:5000" 29 | {%- if cookiecutter.use_celery == "yes" %} 30 | rabbitmq: 31 | image: rabbitmq 32 | redis: 33 | image: redis 34 | celery: 35 | image: {{cookiecutter.app_name}} 36 | command: "celery worker -A {{cookiecutter.app_name}}.celery_app:app" 37 | env_file: 38 | - ./.flaskenv 39 | volumes: 40 | - .:/code 41 | depends_on: 42 | - rabbitmq 43 | environment: 44 | - CELERY_BROKER_URL=amqp://guest:guest@rabbitmq 45 | - CELERY_RESULT_BACKEND_URL=redis://redis 46 | {%- endif %} 47 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/{{cookiecutter.app_name}}/commons/templates/swagger.j2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{cookiecutter.project_name}} - Swagger 6 | 7 | 26 | 27 | {% raw %} 28 |
29 | 30 | 31 | {% endraw %} 50 | 51 | 52 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/{{cookiecutter.app_name}}/models/blocklist.py: -------------------------------------------------------------------------------- 1 | """Simple blocklist implementation using database 2 | 3 | Using database may not be your prefered solution to handle blocklist in your 4 | final application, but remember that's just a cookiecutter template. Feel free 5 | to dump this code and adapt it for your needs. 6 | 7 | For this reason, we don't include advanced tokens management in this 8 | example (view all tokens for a user, revoke from api, etc.) 9 | 10 | If we choose to use database to handle blocklist in this example, it's mainly 11 | because it will allow you to run the example without needing to setup anything else 12 | like a redis or a memcached server. 13 | 14 | This example is heavily inspired by 15 | https://github.com/vimalloc/flask-jwt-extended/blob/master/examples/blocklist_database.py 16 | """ 17 | from {{cookiecutter.app_name}}.extensions import db 18 | 19 | 20 | class TokenBlocklist(db.Model): 21 | """Blocklist representation""" 22 | 23 | id = db.Column(db.Integer, primary_key=True) 24 | jti = db.Column(db.String(36), nullable=False, unique=True) 25 | token_type = db.Column(db.String(10), nullable=False) 26 | user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) 27 | revoked = db.Column(db.Boolean, nullable=False) 28 | expires = db.Column(db.DateTime, nullable=False) 29 | 30 | user = db.relationship("User", lazy="joined") 31 | 32 | def to_dict(self): 33 | return { 34 | "token_id": self.id, 35 | "jti": self.jti, 36 | "token_type": self.token_type, 37 | "user_identity": self.user_identity, 38 | "revoked": self.revoked, 39 | "expires": self.expires, 40 | } 41 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import json 2 | import pytest 3 | from dotenv import load_dotenv 4 | 5 | from {{cookiecutter.app_name}}.models import User 6 | from {{cookiecutter.app_name}}.app import create_app 7 | from {{cookiecutter.app_name}}.extensions import db as _db 8 | from pytest_factoryboy import register 9 | from tests.factories import UserFactory 10 | 11 | 12 | register(UserFactory) 13 | 14 | 15 | @pytest.fixture(scope="session") 16 | def app(): 17 | load_dotenv(".testenv") 18 | app = create_app(testing=True) 19 | return app 20 | 21 | 22 | @pytest.fixture 23 | def db(app): 24 | _db.app = app 25 | 26 | with app.app_context(): 27 | _db.create_all() 28 | 29 | yield _db 30 | 31 | _db.session.close() 32 | _db.drop_all() 33 | 34 | 35 | @pytest.fixture 36 | def admin_user(db): 37 | user = User( 38 | username='admin', 39 | email='admin@admin.com', 40 | password='admin' 41 | ) 42 | 43 | db.session.add(user) 44 | db.session.commit() 45 | 46 | return user 47 | 48 | 49 | @pytest.fixture 50 | def admin_headers(admin_user, client): 51 | data = { 52 | 'username': admin_user.username, 53 | 'password': 'admin' 54 | } 55 | rep = client.post( 56 | '/auth/login', 57 | data=json.dumps(data), 58 | headers={'content-type': 'application/json'} 59 | ) 60 | 61 | tokens = json.loads(rep.get_data(as_text=True)) 62 | return { 63 | 'content-type': 'application/json', 64 | 'authorization': 'Bearer %s' % tokens['access_token'] 65 | } 66 | 67 | 68 | @pytest.fixture 69 | def admin_refresh_headers(admin_user, client): 70 | data = { 71 | 'username': admin_user.username, 72 | 'password': 'admin' 73 | } 74 | rep = client.post( 75 | '/auth/login', 76 | data=json.dumps(data), 77 | headers={'content-type': 'application/json'} 78 | ) 79 | 80 | tokens = json.loads(rep.get_data(as_text=True)) 81 | return { 82 | 'content-type': 'application/json', 83 | 'authorization': 'Bearer %s' % tokens['refresh_token'] 84 | } 85 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/{{cookiecutter.app_name}}/auth/helpers.py: -------------------------------------------------------------------------------- 1 | """Various helpers for auth. Mainly about tokens blocklisting 2 | 3 | Heavily inspired by 4 | https://github.com/vimalloc/flask-jwt-extended/blob/master/examples/blocklist_database.py 5 | """ 6 | from datetime import datetime 7 | 8 | from flask_jwt_extended import decode_token 9 | from sqlalchemy.orm.exc import NoResultFound 10 | 11 | from {{cookiecutter.app_name}}.extensions import db 12 | from {{cookiecutter.app_name}}.models import TokenBlocklist 13 | 14 | 15 | def add_token_to_database(encoded_token, identity_claim): 16 | """ 17 | Adds a new token to the database. It is not revoked when it is added. 18 | 19 | :param identity_claim: configured key to get user identity 20 | """ 21 | decoded_token = decode_token(encoded_token) 22 | jti = decoded_token["jti"] 23 | token_type = decoded_token["type"] 24 | user_identity = decoded_token[identity_claim] 25 | expires = datetime.fromtimestamp(decoded_token["exp"]) 26 | revoked = False 27 | 28 | db_token = TokenBlocklist( 29 | jti=jti, 30 | token_type=token_type, 31 | user_id=user_identity, 32 | expires=expires, 33 | revoked=revoked, 34 | ) 35 | db.session.add(db_token) 36 | db.session.commit() 37 | 38 | 39 | def is_token_revoked(jwt_payload): 40 | """ 41 | Checks if the given token is revoked or not. Because we are adding all the 42 | tokens that we create into this database, if the token is not present 43 | in the database we are going to consider it revoked, as we don't know where 44 | it was created. 45 | """ 46 | jti = jwt_payload["jti"] 47 | try: 48 | token = TokenBlocklist.query.filter_by(jti=jti).one() 49 | return token.revoked 50 | except NoResultFound: 51 | return True 52 | 53 | 54 | def revoke_token(token_jti, user): 55 | """Revokes the given token 56 | 57 | Since we use it only on logout that already require a valid access token, 58 | if token is not found we raise an exception 59 | """ 60 | try: 61 | token = TokenBlocklist.query.filter_by(jti=token_jti, user_id=user).one() 62 | token.revoked = True 63 | db.session.commit() 64 | except NoResultFound: 65 | raise Exception("Could not find the token {}".format(token_jti)) 66 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/{{cookiecutter.app_name}}/app.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | from {{cookiecutter.app_name}} import api 3 | from {{cookiecutter.app_name}} import auth 4 | from {{cookiecutter.app_name}} import manage 5 | from {{cookiecutter.app_name}}.extensions import apispec 6 | from {{cookiecutter.app_name}}.extensions import db 7 | from {{cookiecutter.app_name}}.extensions import jwt 8 | from {{cookiecutter.app_name}}.extensions import migrate 9 | 10 | {%- if cookiecutter.use_celery == "yes"%}, celery{% endif%} 11 | 12 | 13 | def create_app(testing=False): 14 | """Application factory, used to create application""" 15 | app = Flask("{{cookiecutter.app_name}}") 16 | app.config.from_object("{{cookiecutter.app_name}}.config") 17 | 18 | if testing is True: 19 | app.config["TESTING"] = True 20 | 21 | configure_extensions(app) 22 | configure_cli(app) 23 | configure_apispec(app) 24 | register_blueprints(app) 25 | {%- if cookiecutter.use_celery == "yes" %} 26 | init_celery(app) 27 | {%- endif %} 28 | 29 | return app 30 | 31 | 32 | def configure_extensions(app): 33 | """Configure flask extensions""" 34 | db.init_app(app) 35 | jwt.init_app(app) 36 | migrate.init_app(app, db) 37 | 38 | 39 | def configure_cli(app): 40 | """Configure Flask 2.0's cli for easy entity management""" 41 | app.cli.add_command(manage.init) 42 | 43 | 44 | def configure_apispec(app): 45 | """Configure APISpec for swagger support""" 46 | apispec.init_app(app, security=[{"jwt": []}]) 47 | apispec.spec.components.security_scheme( 48 | "jwt", {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"} 49 | ) 50 | apispec.spec.components.schema( 51 | "PaginatedResult", 52 | { 53 | "properties": { 54 | "total": {"type": "integer"}, 55 | "pages": {"type": "integer"}, 56 | "next": {"type": "string"}, 57 | "prev": {"type": "string"}, 58 | } 59 | }, 60 | ) 61 | 62 | 63 | def register_blueprints(app): 64 | """Register all blueprints for application""" 65 | app.register_blueprint(auth.views.blueprint) 66 | app.register_blueprint(api.views.blueprint) 67 | {%- if cookiecutter.use_celery == "yes" %} 68 | 69 | 70 | def init_celery(app=None): 71 | app = app or create_app() 72 | celery.conf.update(app.config.get("CELERY", {})) 73 | 74 | class ContextTask(celery.Task): 75 | """Make celery tasks work with Flask app context""" 76 | 77 | def __call__(self, *args, **kwargs): 78 | with app.app_context(): 79 | return self.run(*args, **kwargs) 80 | 81 | celery.Task = ContextTask 82 | return celery 83 | {%- endif %} 84 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/tests/test_user.py: -------------------------------------------------------------------------------- 1 | from flask import url_for 2 | 3 | from {{cookiecutter.app_name}}.extensions import pwd_context 4 | from {{cookiecutter.app_name}}.models import User 5 | 6 | 7 | def test_get_user(client, db, user, admin_headers): 8 | # test 404 9 | user_url = url_for('api.user_by_id', user_id="100000") 10 | rep = client.get(user_url, headers=admin_headers) 11 | assert rep.status_code == 404 12 | 13 | db.session.add(user) 14 | db.session.commit() 15 | 16 | # test get_user 17 | user_url = url_for('api.user_by_id', user_id=user.id) 18 | rep = client.get(user_url, headers=admin_headers) 19 | assert rep.status_code == 200 20 | 21 | data = rep.get_json()["user"] 22 | assert data["username"] == user.username 23 | assert data["email"] == user.email 24 | assert data["active"] == user.active 25 | 26 | 27 | def test_put_user(client, db, user, admin_headers): 28 | # test 404 29 | user_url = url_for('api.user_by_id', user_id="100000") 30 | rep = client.put(user_url, headers=admin_headers) 31 | assert rep.status_code == 404 32 | 33 | db.session.add(user) 34 | db.session.commit() 35 | 36 | data = {"username": "updated", "password": "new_password"} 37 | 38 | user_url = url_for('api.user_by_id', user_id=user.id) 39 | # test update user 40 | rep = client.put(user_url, json=data, headers=admin_headers) 41 | assert rep.status_code == 200 42 | 43 | data = rep.get_json()["user"] 44 | assert data["username"] == "updated" 45 | assert data["email"] == user.email 46 | assert data["active"] == user.active 47 | 48 | db.session.refresh(user) 49 | assert pwd_context.verify("new_password", user.password) 50 | 51 | 52 | def test_delete_user(client, db, user, admin_headers): 53 | # test 404 54 | user_url = url_for('api.user_by_id', user_id="100000") 55 | rep = client.delete(user_url, headers=admin_headers) 56 | assert rep.status_code == 404 57 | 58 | db.session.add(user) 59 | db.session.commit() 60 | 61 | # test get_user 62 | 63 | user_url = url_for('api.user_by_id', user_id=user.id) 64 | rep = client.delete(user_url, headers=admin_headers) 65 | assert rep.status_code == 200 66 | assert db.session.query(User).filter_by(id=user.id).first() is None 67 | 68 | 69 | def test_create_user(client, db, admin_headers): 70 | # test bad data 71 | users_url = url_for('api.users') 72 | data = {"username": "created"} 73 | rep = client.post(users_url, json=data, headers=admin_headers) 74 | assert rep.status_code == 400 75 | 76 | data["password"] = "admin" 77 | data["email"] = "create@mail.com" 78 | 79 | rep = client.post(users_url, json=data, headers=admin_headers) 80 | assert rep.status_code == 201 81 | 82 | data = rep.get_json() 83 | user = db.session.query(User).filter_by(id=data["user"]["id"]).first() 84 | 85 | assert user.username == "created" 86 | assert user.email == "create@mail.com" 87 | 88 | 89 | def test_get_all_user(client, db, user_factory, admin_headers): 90 | users_url = url_for('api.users') 91 | users = user_factory.create_batch(30) 92 | 93 | db.session.add_all(users) 94 | db.session.commit() 95 | 96 | rep = client.get(users_url, headers=admin_headers) 97 | assert rep.status_code == 200 98 | 99 | results = rep.get_json() 100 | for user in users: 101 | assert any(u["id"] == user.id for u in results["results"]) 102 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/{{cookiecutter.app_name}}/commons/apispec.py: -------------------------------------------------------------------------------- 1 | from flask import jsonify, render_template, Blueprint 2 | from apispec import APISpec 3 | from apispec.exceptions import APISpecError 4 | from apispec.ext.marshmallow import MarshmallowPlugin 5 | from apispec_webframeworks.flask import FlaskPlugin 6 | 7 | 8 | class FlaskRestfulPlugin(FlaskPlugin): 9 | """Small plugin override to handle flask-restful resources""" 10 | 11 | @staticmethod 12 | def _rule_for_view(view, app=None): 13 | view_funcs = app.view_functions 14 | endpoint = None 15 | 16 | for ept, view_func in view_funcs.items(): 17 | if hasattr(view_func, "view_class"): 18 | view_func = view_func.view_class 19 | 20 | if view_func == view: 21 | endpoint = ept 22 | 23 | if not endpoint: 24 | raise APISpecError("Could not find endpoint for view {0}".format(view)) 25 | 26 | # WARNING: Assume 1 rule per view function for now 27 | rule = app.url_map._rules_by_endpoint[endpoint][0] 28 | return rule 29 | 30 | 31 | class APISpecExt: 32 | """Very simple and small extension to use apispec with this API as a flask extension""" 33 | 34 | def __init__(self, app=None, **kwargs): 35 | self.spec = None 36 | 37 | if app is not None: 38 | self.init_app(app, **kwargs) 39 | 40 | def init_app(self, app, **kwargs): 41 | app.config.setdefault("APISPEC_TITLE", "{{cookiecutter.project_name}}") 42 | app.config.setdefault("APISPEC_VERSION", "1.0.0") 43 | app.config.setdefault("OPENAPI_VERSION", "3.0.2") 44 | app.config.setdefault("SWAGGER_JSON_URL", "/swagger.json") 45 | app.config.setdefault("SWAGGER_UI_URL", "/swagger-ui") 46 | app.config.setdefault("OPENAPI_YAML_URL", "/openapi.yaml") 47 | app.config.setdefault("REDOC_UI_URL", "/redoc-ui") 48 | app.config.setdefault("SWAGGER_URL_PREFIX", None) 49 | 50 | self.spec = APISpec( 51 | title=app.config["APISPEC_TITLE"], 52 | version=app.config["APISPEC_VERSION"], 53 | openapi_version=app.config["OPENAPI_VERSION"], 54 | plugins=[MarshmallowPlugin(), FlaskRestfulPlugin()], 55 | **kwargs 56 | ) 57 | 58 | blueprint = Blueprint( 59 | "swagger", 60 | __name__, 61 | template_folder="./templates", 62 | url_prefix=app.config["SWAGGER_URL_PREFIX"], 63 | ) 64 | 65 | blueprint.add_url_rule(app.config["SWAGGER_JSON_URL"], "swagger_json", self.swagger_json) 66 | blueprint.add_url_rule(app.config["SWAGGER_UI_URL"], "swagger_ui", self.swagger_ui) 67 | blueprint.add_url_rule(app.config["OPENAPI_YAML_URL"], "openapi_yaml", self.openapi_yaml) 68 | blueprint.add_url_rule(app.config["REDOC_UI_URL"], "redoc_ui", self.redoc_ui) 69 | 70 | app.register_blueprint(blueprint) 71 | 72 | def swagger_json(self): 73 | return jsonify(self.spec.to_dict()) 74 | 75 | def swagger_ui(self): 76 | return render_template("swagger.j2") 77 | 78 | def openapi_yaml(self): 79 | # Manually inject ReDoc's Authentication legend, then remove it 80 | self.spec.tag( 81 | {"name": "authentication", "x-displayName": "Authentication", "description": ""} 82 | ) 83 | redoc_spec = self.spec.to_yaml() 84 | self.spec._tags.pop(0) 85 | return redoc_spec 86 | 87 | def redoc_ui(self): 88 | return render_template("redoc.j2") 89 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/{{cookiecutter.app_name}}/api/resources/user.py: -------------------------------------------------------------------------------- 1 | from flask import request 2 | from flask_restful import Resource 3 | from flask_jwt_extended import jwt_required 4 | from {{cookiecutter.app_name}}.api.schemas import UserSchema 5 | from {{cookiecutter.app_name}}.models import User 6 | from {{cookiecutter.app_name}}.extensions import db 7 | from {{cookiecutter.app_name}}.commons.pagination import paginate 8 | 9 | 10 | class UserResource(Resource): 11 | """Single object resource 12 | 13 | --- 14 | get: 15 | tags: 16 | - api 17 | summary: Get a user 18 | description: Get a single user by ID 19 | parameters: 20 | - in: path 21 | name: user_id 22 | schema: 23 | type: integer 24 | responses: 25 | 200: 26 | content: 27 | application/json: 28 | schema: 29 | type: object 30 | properties: 31 | user: UserSchema 32 | 404: 33 | description: user does not exists 34 | put: 35 | tags: 36 | - api 37 | summary: Update a user 38 | description: Update a single user by ID 39 | parameters: 40 | - in: path 41 | name: user_id 42 | schema: 43 | type: integer 44 | requestBody: 45 | content: 46 | application/json: 47 | schema: 48 | UserSchema 49 | responses: 50 | 200: 51 | content: 52 | application/json: 53 | schema: 54 | type: object 55 | properties: 56 | msg: 57 | type: string 58 | example: user updated 59 | user: UserSchema 60 | 404: 61 | description: user does not exists 62 | delete: 63 | tags: 64 | - api 65 | summary: Delete a user 66 | description: Delete a single user by ID 67 | parameters: 68 | - in: path 69 | name: user_id 70 | schema: 71 | type: integer 72 | responses: 73 | 200: 74 | content: 75 | application/json: 76 | schema: 77 | type: object 78 | properties: 79 | msg: 80 | type: string 81 | example: user deleted 82 | 404: 83 | description: user does not exists 84 | """ 85 | 86 | method_decorators = [jwt_required()] 87 | 88 | def get(self, user_id): 89 | schema = UserSchema() 90 | user = User.query.get_or_404(user_id) 91 | return {"user": schema.dump(user)} 92 | 93 | def put(self, user_id): 94 | schema = UserSchema(partial=True) 95 | user = User.query.get_or_404(user_id) 96 | user = schema.load(request.json, instance=user) 97 | 98 | db.session.commit() 99 | 100 | return {"msg": "user updated", "user": schema.dump(user)} 101 | 102 | def delete(self, user_id): 103 | user = User.query.get_or_404(user_id) 104 | db.session.delete(user) 105 | db.session.commit() 106 | 107 | return {"msg": "user deleted"} 108 | 109 | 110 | class UserList(Resource): 111 | """Creation and get_all 112 | 113 | --- 114 | get: 115 | tags: 116 | - api 117 | summary: Get a list of users 118 | description: Get a list of paginated users 119 | responses: 120 | 200: 121 | content: 122 | application/json: 123 | schema: 124 | allOf: 125 | - $ref: '#/components/schemas/PaginatedResult' 126 | - type: object 127 | properties: 128 | results: 129 | type: array 130 | items: 131 | $ref: '#/components/schemas/UserSchema' 132 | post: 133 | tags: 134 | - api 135 | summary: Create a user 136 | description: Create a new user 137 | requestBody: 138 | content: 139 | application/json: 140 | schema: 141 | UserSchema 142 | responses: 143 | 201: 144 | content: 145 | application/json: 146 | schema: 147 | type: object 148 | properties: 149 | msg: 150 | type: string 151 | example: user created 152 | user: UserSchema 153 | """ 154 | 155 | method_decorators = [jwt_required()] 156 | 157 | def get(self): 158 | schema = UserSchema(many=True) 159 | query = User.query 160 | return paginate(query, schema) 161 | 162 | def post(self): 163 | schema = UserSchema() 164 | user = schema.load(request.json) 165 | 166 | db.session.add(user) 167 | db.session.commit() 168 | 169 | return {"msg": "user created", "user": schema.dump(user)}, 201 170 | -------------------------------------------------------------------------------- /{{cookiecutter.project_name}}/{{cookiecutter.app_name}}/auth/views.py: -------------------------------------------------------------------------------- 1 | from flask import request, jsonify, Blueprint, current_app as app 2 | from flask_jwt_extended import ( 3 | create_access_token, 4 | create_refresh_token, 5 | jwt_required, 6 | get_jwt_identity, 7 | get_jwt, 8 | ) 9 | 10 | from {{cookiecutter.app_name}}.models import User 11 | from {{cookiecutter.app_name}}.extensions import pwd_context, jwt, apispec 12 | from {{cookiecutter.app_name}}.auth.helpers import revoke_token, is_token_revoked, add_token_to_database 13 | 14 | 15 | blueprint = Blueprint("auth", __name__, url_prefix="/auth") 16 | 17 | 18 | @blueprint.route("/login", methods=["POST"]) 19 | def login(): 20 | """Authenticate user and return tokens 21 | 22 | --- 23 | post: 24 | tags: 25 | - auth 26 | summary: Authenticate a user 27 | description: Authenticates a user's credentials and returns tokens 28 | requestBody: 29 | content: 30 | application/json: 31 | schema: 32 | type: object 33 | properties: 34 | username: 35 | type: string 36 | example: myuser 37 | required: true 38 | password: 39 | type: string 40 | example: P4$$w0rd! 41 | required: true 42 | responses: 43 | 200: 44 | content: 45 | application/json: 46 | schema: 47 | type: object 48 | properties: 49 | access_token: 50 | type: string 51 | example: myaccesstoken 52 | refresh_token: 53 | type: string 54 | example: myrefreshtoken 55 | 400: 56 | description: bad request 57 | security: [] 58 | """ 59 | if not request.is_json: 60 | return jsonify({"msg": "Missing JSON in request"}), 400 61 | 62 | username = request.json.get("username", None) 63 | password = request.json.get("password", None) 64 | if not username or not password: 65 | return jsonify({"msg": "Missing username or password"}), 400 66 | 67 | user = User.query.filter_by(username=username).first() 68 | if user is None or not pwd_context.verify(password, user.password): 69 | return jsonify({"msg": "Bad credentials"}), 400 70 | 71 | access_token = create_access_token(identity=user.id) 72 | refresh_token = create_refresh_token(identity=user.id) 73 | add_token_to_database(access_token, app.config["JWT_IDENTITY_CLAIM"]) 74 | add_token_to_database(refresh_token, app.config["JWT_IDENTITY_CLAIM"]) 75 | 76 | ret = {"access_token": access_token, "refresh_token": refresh_token} 77 | return jsonify(ret), 200 78 | 79 | 80 | @blueprint.route("/refresh", methods=["POST"]) 81 | @jwt_required(refresh=True) 82 | def refresh(): 83 | """Get an access token from a refresh token 84 | 85 | --- 86 | post: 87 | tags: 88 | - auth 89 | summary: Get an access token 90 | description: Get an access token by using a refresh token in the `Authorization` header 91 | responses: 92 | 200: 93 | content: 94 | application/json: 95 | schema: 96 | type: object 97 | properties: 98 | access_token: 99 | type: string 100 | example: myaccesstoken 101 | 400: 102 | description: bad request 103 | 401: 104 | description: unauthorized 105 | """ 106 | current_user = get_jwt_identity() 107 | access_token = create_access_token(identity=current_user) 108 | ret = {"access_token": access_token} 109 | add_token_to_database(access_token, app.config["JWT_IDENTITY_CLAIM"]) 110 | return jsonify(ret), 200 111 | 112 | 113 | @blueprint.route("/revoke_access", methods=["DELETE"]) 114 | @jwt_required() 115 | def revoke_access_token(): 116 | """Revoke an access token 117 | 118 | --- 119 | delete: 120 | tags: 121 | - auth 122 | summary: Revoke an access token 123 | description: Revoke an access token 124 | responses: 125 | 200: 126 | content: 127 | application/json: 128 | schema: 129 | type: object 130 | properties: 131 | message: 132 | type: string 133 | example: token revoked 134 | 400: 135 | description: bad request 136 | 401: 137 | description: unauthorized 138 | """ 139 | jti = get_jwt()["jti"] 140 | user_identity = get_jwt_identity() 141 | revoke_token(jti, user_identity) 142 | return jsonify({"message": "token revoked"}), 200 143 | 144 | 145 | @blueprint.route("/revoke_refresh", methods=["DELETE"]) 146 | @jwt_required(refresh=True) 147 | def revoke_refresh_token(): 148 | """Revoke a refresh token, used mainly for logout 149 | 150 | --- 151 | delete: 152 | tags: 153 | - auth 154 | summary: Revoke a refresh token 155 | description: Revoke a refresh token, used mainly for logout 156 | responses: 157 | 200: 158 | content: 159 | application/json: 160 | schema: 161 | type: object 162 | properties: 163 | message: 164 | type: string 165 | example: token revoked 166 | 400: 167 | description: bad request 168 | 401: 169 | description: unauthorized 170 | """ 171 | jti = get_jwt()["jti"] 172 | user_identity = get_jwt_identity() 173 | revoke_token(jti, user_identity) 174 | return jsonify({"message": "token revoked"}), 200 175 | 176 | 177 | @jwt.user_lookup_loader 178 | def user_loader_callback(jwt_headers, jwt_payload): 179 | identity = jwt_payload["sub"] 180 | return User.query.get(identity) 181 | 182 | 183 | @jwt.token_in_blocklist_loader 184 | def check_if_token_revoked(jwt_headers, jwt_payload): 185 | return is_token_revoked(jwt_payload) 186 | 187 | 188 | @blueprint.before_app_first_request 189 | def register_views(): 190 | apispec.spec.path(view=login, app=app) 191 | apispec.spec.path(view=refresh, app=app) 192 | apispec.spec.path(view=revoke_access_token, app=app) 193 | apispec.spec.path(view=revoke_refresh_token, app=app) 194 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cookiecutter-flask-restful 2 | 3 | Cookiecutter template for flask restful, including blueprints, application factory, and more 4 | 5 | [![Build Status](https://travis-ci.org/karec/cookiecutter-flask-restful.svg?branch=master)](https://travis-ci.org/karec/cookiecutter-flask-restful) 6 | 7 | ## Introduction 8 | 9 | This cookie cutter is a very simple boilerplate for starting a REST api using Flask, flask-restful, marshmallow, SQLAlchemy and jwt. 10 | It comes with basic project structure and configuration, including blueprints, application factory and basics unit tests. 11 | 12 | Features 13 | 14 | * Simple flask application using application factory, blueprints 15 | * [Flask command line interface](http://flask.pocoo.org/docs/1.0/cli/) integration 16 | * Simple cli implementation with basics commands (init, run, etc.) 17 | * [Flask Migrate](https://flask-migrate.readthedocs.io/en/latest/) included in entry point 18 | * Authentication using [Flask-JWT-Extended](http://flask-jwt-extended.readthedocs.io/en/latest/) including access token and refresh token management 19 | * Simple pagination utils 20 | * Unit tests using pytest and factoryboy 21 | * Configuration using environment variables 22 | * OpenAPI json file and swagger UI 23 | 24 | Used packages : 25 | 26 | * [Flask](http://flask.pocoo.org/) 27 | * [Flask-RESTful](https://flask-restful.readthedocs.io/en/latest/) 28 | * [Flask-Migrate](https://flask-migrate.readthedocs.io/en/latest/) 29 | * [Flask-SQLAlchemy](http://flask-sqlalchemy.pocoo.org/2.3/) 30 | * [Flask-Marshmallow](https://flask-marshmallow.readthedocs.io/en/latest/) 31 | * [Flask-JWT-Extended](http://flask-jwt-extended.readthedocs.io/en/latest/) 32 | * [marshmallow-sqlalchemy](https://marshmallow-sqlalchemy.readthedocs.io/en/latest/) 33 | * [passlib](https://passlib.readthedocs.io/en/stable/) 34 | * [tox](https://tox.readthedocs.io/en/latest/) 35 | * [pytest](https://docs.pytest.org/en/latest/) 36 | * [factoryboy](http://factoryboy.readthedocs.io/en/latest/) 37 | * [dotenv](https://github.com/theskumar/python-dotenv) 38 | * [apispec](https://github.com/marshmallow-code/apispec) 39 | 40 | 41 | ## Usage 42 | 43 | * [Installation](#installation) 44 | * [Configuration](#configuration) 45 | * [Authentication](#athentication) 46 | * [Running tests](#running-tests) 47 | * [WSGI Server](#installing-a-wsgi-server) 48 | * [Flask CLI](#using-flask-cli) 49 | * [Using Celery](#using-celery) 50 | * [Using Docker](#using-docker) 51 | * [Makefile](#makefile-usage) 52 | * [APISpec, swagger, and redoc](#using-apispec-swagger-and-redoc) 53 | * [Changelog](#changelog) 54 | 55 | 56 | ### Installation 57 | 58 | #### Install cookiecutter 59 | 60 | Make sure you have cookiecutter installed in your local machine. 61 | 62 | You can install it using this command : `pip install cookiecutter` 63 | 64 | #### Create your project 65 | 66 | Starting a new project is as easy as running this command at the command line. No need to create a directory first, the cookiecutter will do it for you. 67 | 68 | To create a project run the following command and follow the prompt 69 | 70 | `cookiecutter https://github.com/karec/cookiecutter-flask-restful` 71 | 72 | #### Install project requirements 73 | 74 | Let's say you named your app `myapi` and your project `myproject` 75 | 76 | You can install it using pip : 77 | 78 | ``` 79 | cd myproject 80 | pip install -r requirements.txt 81 | pip install -e . 82 | ``` 83 | 84 | You now have access to cli commands and can init your project 85 | 86 | ``` 87 | flask db init 88 | flask db migrate 89 | flask db upgrade 90 | flask init # creates admin user 91 | ``` 92 | 93 | To list all commands 94 | 95 | ``` 96 | flask --help 97 | ``` 98 | 99 | ### Configuration 100 | 101 | Configuration is handled by environment variables, for development purpose you just 102 | need to update / add entries in `.flaskenv` file. 103 | 104 | It's filled by default with following content: 105 | 106 | ``` 107 | FLASK_ENV=development 108 | FLASK_APP="myapp.app:create_app" 109 | SECRET_KEY=changeme 110 | DATABASE_URI="sqlite:///myapp.db" 111 | CELERY_BROKER_URL=amqp://guest:guest@localhost/ # only present when celery is enabled 112 | CELERY_RESULT_BACKEND_URL=amqp://guest:guest@localhost/ # only present when celery is enabled 113 | ``` 114 | 115 | Avaible configuration keys: 116 | 117 | * `FLASK_ENV`: flask configuration key, enables `DEBUG` if set to `development` 118 | * `SECREY_KEY`: your application secret key 119 | * `DATABASE_URI`: SQLAlchemy connection string 120 | * `CELERY_BROKER_URL`: URL to use for celery broker, only when you enabled celery 121 | * `CELERY_RESULT_BACKEND_URL`: URL to use for celery result backend (e.g: `redis://localhost`) 122 | 123 | ### Authentication 124 | 125 | 126 | To access protected resources, you will need an access token. You can generate 127 | an access and a refresh token using `/auth/login` endpoint, example using curl 128 | 129 | ```bash 130 | curl -X POST -H "Content-Type: application/json" -d '{"username": "admin", "password": "admin"}' http://localhost:5000/auth/login 131 | ``` 132 | 133 | This will return something like this 134 | 135 | ```json 136 | { 137 | "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoiYWNjZXNzIiwiaWRlbnRpdHkiOjEsImlhdCI6MTUxMDAwMDQ0MSwiZnJlc2giOmZhbHNlLCJqdGkiOiI2OTg0MjZiYi00ZjJjLTQ5MWItYjE5YS0zZTEzYjU3MzFhMTYiLCJuYmYiOjE1MTAwMDA0NDEsImV4cCI6MTUxMDAwMTM0MX0.P-USaEIs35CSVKyEow5UeXWzTQTrrPS_YjVsltqi7N4", 138 | "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZGVudGl0eSI6MSwiaWF0IjoxNTEwMDAwNDQxLCJ0eXBlIjoicmVmcmVzaCIsImp0aSI6IjRmMjgxOTQxLTlmMWYtNGNiNi05YmI1LWI1ZjZhMjRjMmU0ZSIsIm5iZiI6MTUxMDAwMDQ0MSwiZXhwIjoxNTEyNTkyNDQxfQ.SJPsFPgWpZqZpHTc4L5lG_4aEKXVVpLLSW1LO7g4iU0" 139 | } 140 | ``` 141 | You can use access_token to access protected endpoints : 142 | 143 | ```bash 144 | curl -X GET -H "Content-Type: application/json" -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoiYWNjZXNzIiwiaWRlbnRpdHkiOjEsImlhdCI6MTUxMDAwMDQ0MSwiZnJlc2giOmZhbHNlLCJqdGkiOiI2OTg0MjZiYi00ZjJjLTQ5MWItYjE5YS0zZTEzYjU3MzFhMTYiLCJuYmYiOjE1MTAwMDA0NDEsImV4cCI6MTUxMDAwMTM0MX0.P-USaEIs35CSVKyEow5UeXWzTQTrrPS_YjVsltqi7N4" http://127.0.0.1:5000/api/v1/users 145 | ``` 146 | 147 | You can use refresh token to retreive a new access_token using the endpoint `/auth/refresh` 148 | 149 | 150 | ```bash 151 | curl -X POST -H "Content-Type: application/json" -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZGVudGl0eSI6MSwiaWF0IjoxNTEwMDAwNDQxLCJ0eXBlIjoicmVmcmVzaCIsImp0aSI6IjRmMjgxOTQxLTlmMWYtNGNiNi05YmI1LWI1ZjZhMjRjMmU0ZSIsIm5iZiI6MTUxMDAwMDQ0MSwiZXhwIjoxNTEyNTkyNDQxfQ.SJPsFPgWpZqZpHTc4L5lG_4aEKXVVpLLSW1LO7g4iU0" http://127.0.0.1:5000/auth/refresh 152 | ``` 153 | 154 | This will only return a new access token 155 | 156 | ```json 157 | { 158 | "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoiYWNjZXNzIiwiaWRlbnRpdHkiOjEsImlhdCI6MTUxMDAwMDYxOCwiZnJlc2giOmZhbHNlLCJqdGkiOiIzODcxMzg4Ni0zNGJjLTRhOWQtYmFlYS04MmZiNmQwZjEyNjAiLCJuYmYiOjE1MTAwMDA2MTgsImV4cCI6MTUxMDAwMTUxOH0.cHuNf-GxVFJnUZ_k9ycoMMb-zvZ10Y4qbrW8WkXdlpw" 159 | } 160 | ``` 161 | 162 | ### Running tests 163 | 164 | 165 | #### Using tox 166 | 167 | Simplest way to run tests is to use tox, it will create a virtualenv for tests, install all dependencies and run pytest 168 | 169 | ``` 170 | tox 171 | ``` 172 | 173 | If you just want to run pytest and avoid linters you can use 174 | 175 | ``` 176 | tox -e test 177 | ``` 178 | 179 | #### Using pytest directly 180 | 181 | If you want to run pytest manually without using tox you'll need to install some dependencies before 182 | 183 | ``` 184 | pip install pytest pytest-runner pytest-flask pytest-factoryboy pytest-celery factory_boy 185 | ``` 186 | 187 | Then you can invoke pytest 188 | 189 | ``` 190 | pytest 191 | ``` 192 | 193 | Note that tox is setting environment variables for you when testing, but when using pytest directly that's not the case. To avoid setting up env variables each time you run pytest, this cookiecutter provide a `.testenv` file that contains default configuration for testing. Don't forget to update it if your local env doesn't match those defaults. 194 | 195 | #### Using docker 196 | 197 | Testing with docker is another great option, since it take cares of everything and spawn required services for you. To run tests within docker containers, you can use the provided Makefile: 198 | 199 | Build images: 200 | 201 | ```bash 202 | make build 203 | ``` 204 | 205 | Running tox with flake8, black and pytest: 206 | 207 | ```bash 208 | make tox 209 | ``` 210 | 211 | Running tox with pytest only: 212 | 213 | ```bash 214 | make test 215 | ``` 216 | 217 | #### Testing Celery 218 | 219 | Testing celery require at least a rabbitMQ (or any other compatible broker) running. By default, when you use tox or the `.testenv` file, celery broker and result backend are configured as follow: 220 | 221 | ``` 222 | CELERY_BROKER_URL=amqp://guest:guest@localhost/ 223 | CELERY_RESULT_BACKEND_URL=amqp://guest:guest@localhost/ 224 | ``` 225 | 226 | Meaning that it will try to connect to a local rabbitMQ server using guest user. Don't forget to update those settings if your configuration doesn't match. 227 | 228 | If you can't / don't want to install a local rabbitMQ server or any other available celery broker, you have 2 options: 229 | 230 | 1. Use docker 231 | 232 | You can use docker-compose to run tests, as it will spawn a rabbitMQ and a redis servera and set correct env variables for configuration. All tests commands are available inside the Makefile to simplify this process. 233 | 234 | 2. Update the tests to use eager mode 235 | 236 | **NOTE** this is not recommanded by celery: https://docs.celeryproject.org/en/stable/userguide/testing.html 237 | 238 | Alternatively, if you don't have a local broker and can't use docker, you can update unit tests to run them using the `task_always_eager` celery setting. This will actually run all tasks locally by blocking until tasks return (see https://docs.celeryproject.org/en/stable/userguide/configuration.html#std:setting-task_always_eager for more details). 239 | 240 | Example of `test_celery.py` file that use `task_always_eager` 241 | 242 | ```python 243 | import pytest 244 | 245 | from myapi.app import init_celery 246 | from myapi.tasks.example import dummy_task 247 | 248 | 249 | @pytest.fixture(scope="session") 250 | def celery_session_app(celery_session_app, app): 251 | celery = init_celery(app) 252 | 253 | celery_session_app.conf = celery.conf 254 | celery_session_app.conf.task_always_eager = True 255 | celery_session_app.Task = celery_session_app.Task 256 | 257 | yield celery_session_app 258 | 259 | 260 | def test_example(celery_session_app): 261 | """Simply test our dummy task using celery""" 262 | res = dummy_task.delay() 263 | assert res.get() == "OK" 264 | ``` 265 | 266 | ### Installing a wsgi server 267 | #### Running with gunicorn 268 | 269 | This project provide a simple wsgi entry point to run gunicorn or uwsgi for example. 270 | 271 | For gunicorn you only need to run the following commands 272 | 273 | ``` 274 | pip install gunicorn 275 | gunicorn myapi.wsgi:app 276 | ``` 277 | 278 | And that's it ! Gunicorn is running on port 8000 279 | 280 | If you chose gunicorn as your wsgi server, the proper commands should be in your docker-compose file. 281 | 282 | #### Running with uwsgi 283 | 284 | Pretty much the same as gunicorn here 285 | 286 | ``` 287 | pip install uwsgi 288 | uwsgi --http 127.0.0.1:5000 --module myapi.wsgi:app 289 | ``` 290 | 291 | And that's it ! Uwsgi is running on port 5000 292 | 293 | If you chose uwsgi as your wsgi server, the proper commands should be in your docker-compose file. 294 | 295 | ### Using Flask CLI 296 | 297 | This cookiecutter is fully compatible with default flask CLI and use a `.flaskenv` file to set correct env variables to bind the application factory. 298 | Note that we also set `FLASK_ENV` to `development` to enable debugger. 299 | 300 | 301 | ### Using Celery 302 | 303 | This cookiecutter has an optional [Celery](http://www.celeryproject.org/) integration that let you choose if you want to use it or not in your project. 304 | If you choose to use Celery, additionnal code and files will be generated to get started with it. 305 | 306 | This code will include a dummy task located in `yourproject/yourapp/tasks/example.py` that only return `"OK"` and a `celery_app` file used to your celery workers. 307 | 308 | 309 | #### Running celery workers 310 | 311 | In your project path, once dependencies are installed, you can just run 312 | 313 | ``` 314 | celery -A myapi.celery_app:app worker --loglevel=info 315 | ``` 316 | 317 | If you have updated your configuration for broker / result backend your workers should start and you should see the example task avaible 318 | 319 | ``` 320 | [tasks] 321 | . myapi.tasks.example.dummy_task 322 | ``` 323 | 324 | 325 | #### Running a task 326 | 327 | To run a task you can either import it and call it 328 | 329 | ```python 330 | >>> from myapi.tasks.example import dummy_task 331 | >>> result = dummy_task.delay() 332 | >>> result.get() 333 | 'OK' 334 | ``` 335 | 336 | Or use the celery extension 337 | 338 | ```python 339 | >>> from myapi.extensions import celery 340 | >>> celery.send_task('myapi.tasks.example.dummy_task').get() 341 | 'OK' 342 | ``` 343 | 344 | ## Using docker 345 | 346 | **WARNING** both Dockerfile and `docker-compose.yml` are **NOT** suited for production, use them for development only or as a starting point. 347 | 348 | This template offer simple docker support to help you get started and it comes with both Dockerfile and a `docker-compose.yml`. Please note that docker-compose is mostly useful when using celery 349 | since it takes care of running rabbitmq, redis, your web API and celery workers at the same time, but it also work if you don't use celery at all. 350 | 351 | Dockerfile has intentionally no entrypoint to allow you to run any command from it (server, shell, init, celery, ...) 352 | 353 | Note that you still need to init your app on first start, even when using compose. 354 | 355 | ```bash 356 | docker build -t myapp . 357 | ... 358 | docker run --env-file=.flaskenv myapp init 359 | docker run --env-file=.flaskenv -p 5000:5000 myapp run -h 0.0.0.0 360 | * Serving Flask app "myapi.app:create_app" (lazy loading) 361 | * Environment: development 362 | * Debug mode: on 363 | * Running on http://0.0.0.0:5000/ (Press CTRL+C to quit) 364 | * Restarting with stat 365 | * Debugger is active! 366 | * Debugger PIN: 214-619-010 367 | ``` 368 | 369 | With compose 370 | 371 | ```bash 372 | docker-compose up 373 | ... 374 | docker exec -it flask init 375 | ``` 376 | 377 | With docker-compose and the Makefile 378 | ```bash 379 | make init 380 | ``` 381 | 382 | ## Makefile usage 383 | 384 | Initizalize the environment 385 | ```bash 386 | make init 387 | ``` 388 | 389 | Build the containers 390 | ```bash 391 | make build 392 | ``` 393 | 394 | Run the containers 395 | ```bash 396 | make run 397 | ``` 398 | 399 | Create migrations folder and database 400 | ```bash 401 | make db-init 402 | ``` 403 | 404 | Create new database migration 405 | ```bash 406 | make db-migrate 407 | ``` 408 | 409 | Apply database migrations 410 | ```bash 411 | make db-upgrade 412 | ``` 413 | 414 | Run tests inside containers 415 | ```bash 416 | make test 417 | ``` 418 | 419 | ## Using APISpec, Swagger, and ReDoc 420 | 421 | This boilerplate comes with pre-configured APISpec and swagger endpoints. Using default configuration you have four endpoints available: 422 | 423 | * `/swagger.json`: return OpenAPI specification file in json format 424 | * `/swagger-ui`: Swagger UI configured to hit OpenAPI json file 425 | * `/openapi.yaml`: return OpenAPI specification file in yaml format 426 | * `/redoc-ui`: ReDoc UI configured to hit OpenAPI yaml file 427 | 428 | This come with a very simple extension that allow you to override basic settings of APISpec using your `config.py` file: 429 | 430 | * `APISPEC_TITLE`: title for your spec, default to `{{cookiecutter.project_name}}` 431 | * `APISPEC_VERSION`: version of your API, default to `1.0.0` 432 | * `OPENAPI_VERSION`: OpenAPI version of your spec, default to `3.0.2` 433 | * `SWAGGER_JSON_URL`: Url for your JSON specifications, default to `/swagger.json` 434 | * `SWAGGER_UI_URL`: Url for swagger-ui, default to `/swagger-ui` 435 | * `OPENAPI_YAML_URL`: Url for your YAML specifications, default to `/openapi.yaml` 436 | * `REDOC_UI_URL`: Url for redoc-ui, default to `/redoc-ui` 437 | * `SWAGGER_URL_PREFIX`: URL prefix to use for swagger blueprint, default to `None` 438 | 439 | ## Changelog 440 | 441 | ### 29/10/2021 442 | 443 | * Updated readme makefile calls 444 | * Fixed Makefile 445 | * Removed entrypoint from setup to use flask default CLI 446 | * Re-format apispec to fit black specs 447 | 448 | ### 9/24/2021 449 | 450 | * Fixed CLI to work with Flask 2.0's built-in CLI 451 | * Added ReDoc UI and YAML OpenAPI Spec routes 452 | * Updated Swagger UI version to fix previously-distorted version 453 | * Updated README to reflect new CLI and ReDoc information 454 | 455 | ### 6/08/2020 456 | 457 | * Updated README for tests and celery 458 | * Added a `.testenv` file to avoid needing to set env variables when running pytest manually 459 | * Updated celery fixtures to use session fixtures (for worker and app) 460 | * Replaced `prefork` in `celery_worker_pool` by `solo` (#41) 461 | 462 | ### 18/01/2020 463 | 464 | * Added python 3.8 support 465 | * Upgraded to marshmallow 3 466 | * Added `lint` and `tests` envs to tox 467 | * Added black support 468 | * Improved travis tests 469 | * Updated Makefile to handle tests with celery 470 | * Updated tox to handle env variables for celery when runing tests 471 | * Added initial db migration instead of relying on `db.create_all()` 472 | * Added new step to create database in README 473 | * Various cleanup 474 | 475 | ### 08/2019 476 | 477 | * Added apispec dependencies 478 | * Registered `users` endpoints into swagger 479 | * New `apispec` extension 480 | * Added two new routes `/swagger.json` and `/swagger-ui` (configurable urls) 481 | * Added swagger html template 482 | * Add travis file 483 | 484 | ### 26/04/2019 485 | 486 | * Added docker and docker-compose support 487 | 488 | ### 24/04/2019 489 | 490 | * Update configuration to only use env variables, `.flaskenv` has been updated too 491 | * Add unit tests for celery 492 | * Add flake8 to tox 493 | * Configuration file cannot be overridden by `MYAPP CONFIG` env variable anymore 494 | * various cleanups (unused imports, removed `configtest.py` file, flake8 errors) 495 | 496 | --------------------------------------------------------------------------------