├── .gitignore ├── .travis.yml ├── AUTHORS ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── README.md ├── api ├── __init__.py ├── config.py ├── factory.py └── resources │ ├── __init__.py │ └── main.py ├── docker-compose.yml ├── requirements-dev.txt ├── requirements.txt ├── run.py ├── setup.cfg ├── setup.py ├── test ├── __init__.py ├── conftest.py ├── resources │ ├── __init__.py │ ├── test_async.py │ └── test_responses.py └── utils.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | bin/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # Installer logs 26 | pip-log.txt 27 | pip-delete-this-directory.txt 28 | 29 | # Unit test / coverage reports 30 | htmlcov/ 31 | .tox/ 32 | .coverage 33 | .cache 34 | nosetests.xml 35 | coverage.xml 36 | .pytest_cache 37 | 38 | # Translations 39 | *.mo 40 | 41 | # Mr Developer 42 | .mr.developer.cfg 43 | .project 44 | .pydevproject 45 | 46 | # Rope 47 | .ropeproject 48 | 49 | # Django stuff: 50 | *.log 51 | *.pot 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | # Gitlab CI/CD 57 | .gitlab-ci.yml 58 | 59 | # Celery 60 | celeryd.pid 61 | 62 | # Others 63 | .idea 64 | .DS_Store 65 | env 66 | *.swp 67 | .vagrant 68 | tags 69 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.6" 4 | 5 | install: 6 | - pip install -r requirements-dev.txt 7 | 8 | script: 9 | - tox -e test 10 | 11 | after_success: 12 | - coveralls 13 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Matthieu Gouel 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM matthieugouel/python-gunicorn-nginx:latest 2 | MAINTAINER Matthieu Gouel 3 | 4 | # Set the environment package 5 | ENV APP_ENVIRONMENT production 6 | 7 | # Copy the package requirements 8 | COPY requirements.txt /tmp/ 9 | 10 | # Install the package requirements 11 | RUN pip install -U pip 12 | RUN pip install -r /tmp/requirements.txt 13 | 14 | # Copy the application 15 | COPY . /app 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017, 2018 Matthieu Gouel 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 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include requirements.txt 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python3 Flask Rest API with Celery example 2 | 3 | [![Build Status](https://travis-ci.org/matthieugouel/python-flask-celery-example.svg?branch=master)](https://travis-ci.org/matthieugouel/python-flask-celery-example) 4 | [![Coverage Status](https://img.shields.io/coveralls/github/matthieugouel/python-flask-celery-example.svg)](https://coveralls.io/github/matthieugouel/python-flask-celery-example?branch=master) 5 | [![license](https://img.shields.io/github/license/matthieugouel/python-flask-celery-example.svg)](https://github.com/matthieugouel/python-flask-celery-example/blob/master/LICENSE) 6 | 7 | This project is an example of using Flask-restful and celery to perform asynchronous tasks. 8 | 9 | ## Installation 10 | 11 | Note : The installation into a virtualenv is heavily recommended. 12 | 13 | If you want to install the package : 14 | 15 | ``` 16 | pip install . 17 | ``` 18 | 19 | For development purposes, you can install the package in editable mode with the dev requirements. 20 | 21 | ``` 22 | pip install -e . -r requirements-dev.txt 23 | ``` 24 | 25 | ## Usage 26 | 27 | To start the application, you can use the file run.py : 28 | 29 | ``` 30 | python run.py 31 | ``` 32 | 33 | Moreover, to be able to play with celery, you have to first start Redis, then start a celery worker like this : 34 | 35 | ``` 36 | celery -A run.celery worker --loglevel=info 37 | ``` 38 | 39 | Note : It's cleaner to use docker-compose to start the whole application (see the section below). 40 | 41 | ## Usage with Docker Compose 42 | 43 | In order to start the whole system easily, we can use docker-compose : 44 | 45 | ``` 46 | docker-compose up 47 | ``` 48 | 49 | It will start three docker containers : 50 | - Redis 51 | - Flask API 52 | - Celery Worker 53 | 54 | Then, you can access to the API in localhost : 55 | 56 | ``` 57 | curl -X GET -H "Content-Type: application/json" localhost:5000/api/bye/test 58 | ``` 59 | 60 | ## Syntax 61 | 62 | You can check the syntax using flake8 (you must have flake8 package installed first) : 63 | 64 | ``` 65 | flake8 api 66 | ``` 67 | 68 | You can also use tox (you must have tox package installed first) : 69 | 70 | ``` 71 | tox -e lint 72 | ``` 73 | 74 | ## Test coverage 75 | 76 | To execute the test coverage, you must install the package with the dev requirements (see installation section). 77 | 78 | You can run the coverage with the following command : 79 | 80 | ``` 81 | coverage run --source api -m py.test 82 | ``` 83 | 84 | You can also use tox (you must have tox package installed first) : 85 | 86 | ``` 87 | tox -e test 88 | ``` 89 | -------------------------------------------------------------------------------- /api/__init__.py: -------------------------------------------------------------------------------- 1 | """Initialization module of the package.""" 2 | # API Factory imports 3 | from api.factory import Factory 4 | 5 | # API configuration imports 6 | from api.config import Config 7 | 8 | # Version handling 9 | import pkg_resources 10 | 11 | try: 12 | # If the app is packaged 13 | # Get the version of the setup package 14 | __version__ = pkg_resources.get_distribution('api').version 15 | except pkg_resources.DistributionNotFound: # pragma: no cover 16 | # If app is not used as a package 17 | # Hardcode the version from the configuration file 18 | __version__ = Config.VERSION 19 | 20 | 21 | # Instantiation of the factory 22 | factory = Factory() 23 | 24 | # Enable flask instance 25 | factory.set_flask() 26 | 27 | # Enable of the desired plugins 28 | factory.set_celery() 29 | 30 | # API Resources imports 31 | from api.resources import blueprint # noqa: E402 32 | 33 | # Register the blueprint 34 | factory.register(blueprint) 35 | 36 | 37 | __all__ = ['Factory', 'HelloWorld', 'ByeWorld'] 38 | -------------------------------------------------------------------------------- /api/config.py: -------------------------------------------------------------------------------- 1 | """Application Configuration.""" 2 | import os 3 | 4 | 5 | class Config(object): 6 | """Parent configuration class.""" 7 | 8 | DEBUG = False 9 | TESTING = False 10 | CSRF_ENABLED = True 11 | SECRET = os.getenv('SECRET') 12 | 13 | TITLE = "Flask API with Celery" 14 | VERSION = "0.1.0" 15 | DESCRIPTION = "An API Skeleton with Celery." 16 | 17 | CELERY_BROKER_URL = 'redis://localhost:6379/0' 18 | CELERY_RESULT_BACKEND = 'redis://localhost:6379/0' 19 | BROKER_URL = CELERY_BROKER_URL 20 | 21 | 22 | class DevelopmentConfig(Config): 23 | """Configurations for Development.""" 24 | 25 | DEBUG = True 26 | 27 | 28 | class TestingConfig(Config): 29 | """Configurations for Testing.""" 30 | 31 | TESTING = True 32 | DEBUG = True 33 | 34 | CELERY_ALWAYS_EAGER = True 35 | 36 | 37 | class StagingConfig(Config): 38 | """Configurations for Staging.""" 39 | 40 | DEBUG = True 41 | 42 | 43 | class ProductionConfig(Config): 44 | """Configurations for Production.""" 45 | 46 | DEBUG = False 47 | TESTING = False 48 | 49 | CELERY_BROKER_URL = 'redis://redis:6379/0' 50 | CELERY_RESULT_BACKEND = 'redis://redis:6379/0' 51 | BROKER_URL = CELERY_BROKER_URL 52 | 53 | 54 | config = { 55 | 'development': DevelopmentConfig, 56 | 'testing': TestingConfig, 57 | 'staging': StagingConfig, 58 | 'production': ProductionConfig, 59 | 60 | 'default': DevelopmentConfig 61 | } 62 | -------------------------------------------------------------------------------- /api/factory.py: -------------------------------------------------------------------------------- 1 | """Factory module.""" 2 | # Flask based imports 3 | from flask import Flask 4 | 5 | # Plugins based imports 6 | from celery import Celery 7 | 8 | # API configuration imports 9 | from api.config import config 10 | 11 | # System based imports 12 | import os 13 | 14 | 15 | class Factory(object): 16 | """Build the instances needed for the API.""" 17 | 18 | def __init__(self, environment='default'): 19 | """Initialize Factory with the proper environment.""" 20 | # Get the running environment 21 | self._environment = os.getenv("APP_ENVIRONMENT") 22 | if not self._environment: 23 | self._environment = environment 24 | 25 | @property 26 | def environment(self): 27 | """Getter for environment attribute.""" 28 | return self._environment 29 | 30 | @environment.setter 31 | def environment(self, environment): 32 | # Update environment protected variable 33 | self._environment = environment 34 | 35 | # Update Flask configuration 36 | self.flask.config.from_object(config[self._environment]) 37 | 38 | # Update Celery Configuration 39 | self.celery.conf.update(self.flask.config) 40 | 41 | def set_flask(self, **kwargs): 42 | """Flask instantiation.""" 43 | # Flask instance creation 44 | self.flask = Flask(__name__, **kwargs) 45 | 46 | # Flask configuration 47 | self.flask.config.from_object(config[self._environment]) 48 | 49 | # Swagger documentation 50 | self.flask.config.SWAGGER_UI_DOC_EXPANSION = 'list' 51 | self.flask.config.SWAGGER_UI_JSONEDITOR = True 52 | 53 | return self.flask 54 | 55 | def set_celery(self, **kwargs): 56 | """Celery instantiation.""" 57 | # Celery instance creation 58 | self.celery = Celery(__name__, **kwargs) 59 | 60 | # Celery Configuration 61 | self.celery.conf.update(self.flask.config) 62 | 63 | return self.celery 64 | 65 | def register(self, blueprint): 66 | """Register a specified blueprint.""" 67 | self.flask.register_blueprint(blueprint) 68 | -------------------------------------------------------------------------------- /api/resources/__init__.py: -------------------------------------------------------------------------------- 1 | """Initialization module of Resources.""" 2 | # Flask based imports 3 | from flask import Blueprint 4 | from flask_restplus import Api 5 | 6 | # Api based imports 7 | from api.config import Config 8 | 9 | # Resources based imports 10 | from api.resources.main import api as main 11 | 12 | blueprint = Blueprint('api', __name__, url_prefix='/api') 13 | 14 | # API instantiation 15 | api = Api(blueprint, 16 | title=Config.TITLE, 17 | version=Config.VERSION, 18 | description=Config.DESCRIPTION) 19 | 20 | # Namespaces registration 21 | api.add_namespace(main, path='') 22 | -------------------------------------------------------------------------------- /api/resources/main.py: -------------------------------------------------------------------------------- 1 | """Entrypoint of the main API Resources.""" 2 | # Useful to simulate a long action 3 | from time import sleep 4 | 5 | # Flask based imports 6 | from flask_restplus import Resource, Namespace 7 | 8 | # Application based imports 9 | from api import factory 10 | 11 | # Empty name is required to have the desired url path 12 | api = Namespace(name='', description='Main API namespace.') 13 | 14 | # Get the celery instance 15 | celery = factory.celery 16 | 17 | 18 | @api.route('/hello/') 19 | @api.doc(params={'name': 'The name of the person to return hello.'}) 20 | class HelloWorld(Resource): 21 | """HelloWorld resource class.""" 22 | 23 | def get(self, name): 24 | """Get method.""" 25 | return {'hello': name} 26 | 27 | 28 | @api.route('/bye/') 29 | @api.doc(params={'name': 'The name of the person to return bye.'}) 30 | class ByeWorld(Resource): 31 | """ByeWorld resource class.""" 32 | 33 | def get(self, name): 34 | """Get method.""" 35 | # Asynchronous long task that we don't need to know the output 36 | self.asynchronous.apply_async((name,)) 37 | return {'bye': name} 38 | 39 | @staticmethod 40 | @celery.task() 41 | def asynchronous(name): 42 | """Async long task method.""" 43 | sleep(5) 44 | return {'async': name} 45 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | 5 | redis: 6 | image: redis:latest 7 | 8 | api: 9 | build: 10 | context: . 11 | dockerfile: Dockerfile 12 | restart: always 13 | ports: 14 | - "5000:80" 15 | links: 16 | - redis 17 | 18 | worker: 19 | build: 20 | context: . 21 | dockerfile: Dockerfile 22 | command: celery -A run.celery worker --loglevel=info 23 | links: 24 | - redis 25 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | tox 2 | flake8 3 | pytest 4 | pytest-cov 5 | pytest-flake8 6 | coverage 7 | coveralls 8 | werkzeug 9 | -rrequirements.txt 10 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flask 2 | flask-restplus 3 | celery 4 | redis 5 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | """Main entrypint of the application.""" 2 | # Api factory import 3 | from api import factory 4 | 5 | # Eventually force the environment 6 | # factory.environment = 'default' 7 | 8 | # Get flask instance 9 | app = factory.flask 10 | 11 | # Get celery instance 12 | celery = factory.celery 13 | 14 | if __name__ == '__main__': 15 | # Actually run the application 16 | app.run() 17 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = D203, C901 3 | skip = .tox, .git, __pycache__, .pytest_cache, build, dist 4 | max-line-length = 100 5 | max-complexity = 10 6 | 7 | [tool:pytest] 8 | addopts = --cov=api --cov-report term-missing -vs --flake8 9 | norecursedirs = .tox .git __pycache__ .pytest_cache build dist 10 | 11 | [coverage:run] 12 | source = api 13 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Setup module of the package.""" 2 | import uuid 3 | 4 | __author__ = 'Matthieu Gouel ' 5 | from setuptools import setup, find_packages 6 | from pip.req import parse_requirements 7 | 8 | 9 | INSTALL_REQS = parse_requirements('requirements.txt', session=uuid.uuid1()) 10 | REQS = [str(ir.req) for ir in INSTALL_REQS] 11 | 12 | setup( 13 | name="api", 14 | version="0.1.0", 15 | packages=find_packages(), 16 | author="Matthieu Gouel", 17 | author_email="matthieu.gouel@gmail.com", 18 | description="api for Python3 projects", 19 | classifiers=[ 20 | 'Topic :: Utilities', 21 | 'Programming Language :: Python', 22 | 'Programming Language :: Python :: 3', 23 | 'Programming Language :: Python :: 3.6', 24 | 'Operating System :: POSIX :: Linux', 25 | 'Operating System :: MacOS', 26 | ], 27 | url="", 28 | include_package_data=True, 29 | install_requires=REQS 30 | ) 31 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | """Application tests.""" 2 | -------------------------------------------------------------------------------- /test/conftest.py: -------------------------------------------------------------------------------- 1 | """Resources Test Configuration.""" 2 | import pytest 3 | 4 | # Flask based imports 5 | from flask.testing import FlaskClient 6 | 7 | # API based imports 8 | from api import Factory 9 | from api.resources import blueprint 10 | 11 | # Test based imports 12 | from .utils import JSONResponse 13 | 14 | # API asynchronous tasks based imports 15 | # Using resources classes directly will use factory 16 | # So we have to import it to set the environment to `testing` 17 | from api import factory 18 | from api.resources.main import ByeWorld 19 | 20 | # Set the factory environment to `testing` 21 | factory.environment = 'testing' 22 | 23 | 24 | @pytest.yield_fixture(scope='session') 25 | def factory_app(): 26 | """Fixture of factory with testing environment.""" 27 | yield Factory(environment='testing') 28 | 29 | 30 | @pytest.yield_fixture(scope='session') 31 | def flask_app(factory_app): 32 | """Fixture of application creation.""" 33 | factory_app.set_flask() 34 | factory_app.register(blueprint) 35 | yield factory_app 36 | 37 | 38 | @pytest.yield_fixture(scope='session') 39 | def celery_app(factory_app): 40 | """Fixture of celery instance creation.""" 41 | factory_app.set_flask() 42 | factory_app.set_celery() 43 | factory_app.register(blueprint) 44 | yield factory_app 45 | 46 | 47 | @pytest.fixture(scope='session') 48 | def flask_app_client(flask_app): 49 | """Fixture of application client.""" 50 | app = flask_app.flask 51 | app.test_client_class = FlaskClient 52 | app.response_class = JSONResponse 53 | return app.test_client() 54 | 55 | 56 | @pytest.yield_fixture(scope='session') 57 | def byeworld(): 58 | """Fixture of ByeWorld resource.""" 59 | return ByeWorld 60 | -------------------------------------------------------------------------------- /test/resources/__init__.py: -------------------------------------------------------------------------------- 1 | """Resources tests.""" 2 | -------------------------------------------------------------------------------- /test/resources/test_async.py: -------------------------------------------------------------------------------- 1 | """Tests of asynchronous tasks.""" 2 | 3 | 4 | def test_byeworld_async(byeworld): 5 | """Test of ByWorld asynchronous task method.""" 6 | task = byeworld.asynchronous.delay('test') 7 | assert task.get(timeout=10) == {'async': 'test'} 8 | -------------------------------------------------------------------------------- /test/resources/test_responses.py: -------------------------------------------------------------------------------- 1 | """Tests of API respi.""" 2 | import pytest 3 | 4 | 5 | def test_environment(factory_app): 6 | """Test of the application environment.""" 7 | assert factory_app.environment == 'testing' 8 | 9 | 10 | @pytest.mark.parametrize('http_method,http_path', ( 11 | ('GET', '/api/hello/test'), 12 | )) 13 | def test_helloworld(http_method, http_path, flask_app_client): 14 | """Test of HelloWorld class.""" 15 | response = flask_app_client.open(method=http_method, path=http_path) 16 | assert response.status_code == 200 17 | assert response.content_type == 'application/json' 18 | assert response.json == {'hello': 'test'} 19 | 20 | 21 | # Note: Redis must be started in localhost 22 | # Else it will throw a status code 500 23 | @pytest.mark.parametrize('http_method,http_path', ( 24 | ('GET', '/api/bye/test'), 25 | )) 26 | def test_byeworld(http_method, http_path, flask_app_client): 27 | """Test of ByeWorld class.""" 28 | response = flask_app_client.open(method=http_method, path=http_path) 29 | assert response.status_code == 200 30 | assert response.content_type == 'application/json' 31 | assert response.json == {'bye': 'test'} 32 | -------------------------------------------------------------------------------- /test/utils.py: -------------------------------------------------------------------------------- 1 | """Utils helpers.""" 2 | import json 3 | from flask import Response 4 | from werkzeug.utils import cached_property 5 | 6 | 7 | class JSONResponse(Response): 8 | """A Response class with ``.json`` property.""" 9 | 10 | @cached_property 11 | def json(self): 12 | """Json response method.""" 13 | return json.loads(self.get_data(as_text=True)) 14 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py36 3 | 4 | [testenv:lint] 5 | skip_install = True 6 | deps = flake8 7 | commands = flake8 api 8 | 9 | [testenv:test] 10 | usedevelop = true 11 | deps = -rrequirements-dev.txt 12 | commands = py.test --cov=api 13 | --------------------------------------------------------------------------------