Merge pull request #1 from rszamszur/r.0.1.0

Release 0.1.0
This commit is contained in:
Radosław Szamszur
2021-07-27 22:40:16 +02:00
committed by GitHub
57 changed files with 1537 additions and 1 deletions

23
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
name: Build
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.7, 3.8, 3.9]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install wheel setuptools
- name: Build package
run: python setup.py -q sdist bdist_wheel

76
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,76 @@
name: Test
on: [push, pull_request]
jobs:
metrics:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.7, 3.8, 3.9]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install flake8 flake8-docstrings flake8-import-order
- name: Style guide
run: |
flake8 --select=E,W,I --max-line-length 80 --import-order-style pep8 --exclude .git,__pycache__,.eggs,*.egg,.pytest_cache,fastapi_mvc_template/version.py,setup.py,fastapi_mvc_template/__init__.py --tee --output-file=pep8_violations.txt --statistics --count setup.py fastapi_mvc_template
flake8 --select=D --ignore D301 --tee --output-file=pep257_violations.txt --statistics --count setup.py fastapi_mvc_template
- name: Code errors
run: flake8 --select=F --tee --output-file=flake8_code_errors.txt --statistics --count setup.py fastapi_mvc_template
- name: Code complexity
run: flake8 --select=C901 --tee --output-file=code_complexity.txt --count fastapi_mvc_template
- name: TODO
run: flake8 --select=T --tee --output-file=todo_occurence.txt --statistics --count setup.py fastapi_mvc_template tests
unit-tests:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [ 3.7, 3.8, 3.9 ]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install .
pip install pytest pytest-cov pytest-asyncio requests mock aioresponses
- name: Run unit tests
run: py.test tests/unit --junit-xml=xunit-${{ matrix.python-version }}.xml
- name: Run coverage
run: pytest --cov=fastapi_mvc_template --cov-fail-under=70 --cov-report=xml --cov-report=term-missing tests
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v2
with:
fail_ci_if_error: true
files: ./coverage.xml
integration-tests:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [ 3.7, 3.8, 3.9 ]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install .
pip install pytest pytest-cov pytest-asyncio requests mock
- name: Run integration tests
run: py.test tests/integration --junit-xml=xunit-${{ matrix.python-version }}.xml

33
.gitignore vendored Normal file
View File

@@ -0,0 +1,33 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
*.egg-info/
.installed.cfg
*.egg
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*,cover
.hypothesis/
flake8_code_errors.txt
pep257_violations.txt
pep8_violations.txt
code_complexity.txt
xunit-*.xml
# PyCharm
.idea
# virtual
venv
# vagrant
.vagrant

7
CHANGELOG.md Normal file
View File

@@ -0,0 +1,7 @@
# Changelog
This file documents changes to [fastapi-mvc-template](https://github.com/rszamszur/fastapi-mvc-template). The release numbering uses [semantic versioning](http://semver.org).
### 0.1.0
- [X] Initial release

13
Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM alpine@sha256:def822f9851ca422481ec6fee59a9966f12b351c62ccb9aca841526ffaa9f748
LABEL maintainer="Radosław Szamszur, radoslawszamszur@gmail.com"
COPY . /fastapi-mvc-template
RUN apk add gcc clang build-base python3 python3-dev py3-pip && \
pip install /fastapi-mvc-template
EXPOSE 8000/tcp
STOPSIGNAL SIGINT
CMD ["/usr/bin/fastapi", "serve", "--host", "0.0.0.0"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Radosław Szamszur
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

35
Makefile Normal file
View File

@@ -0,0 +1,35 @@
.DEFAULT_GOAL:=help
.EXPORT_ALL_VARIABLES:
ifndef VERBOSE
.SILENT:
endif
# set default shell
SHELL=/usr/bin/env bash -o pipefail -o errexit
# Use the 0.0 tag for testing, it shouldn't clobber any release builds
TAG ?= $(shell cat TAG)
REPO_INFO ?= $(shell git config --get remote.origin.url)
COMMIT_SHA ?= git-$(shell git rev-parse --short HEAD)
help: ## Display this help
@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m<target>\033[0m\n"} /^[a-zA-Z0-9_-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST)
.PHONY: image
image: ## Build ingestion-server image
@build/image.sh
.PHONY: clean-image
clean-image: ## Clean ingestion-server image
@build/clean-image.sh
.PHONY: venv
venv: ## Install project locally to virtualenv
@build/venv.sh
.PHONY: show-version
show-version:
echo -n $(TAG)

288
README.md
View File

@@ -1,2 +1,288 @@
# fastapi-mvc-template
# FastAPI-MVC-template
![Build](https://github.com/rszamszur/fastapi-mvc-template/actions/workflows/build.yml/badge.svg)
![Test](https://github.com/rszamszur/fastapi-mvc-template/actions/workflows/test.yml/badge.svg)
[![codecov](https://codecov.io/gh/rszamszur/fastapi-mvc-template/branch/master/graph/badge.svg?token=7ESV30TYZS)](https://codecov.io/gh/rszamszur/fastapi-mvc-template)
FastAPI project core implemented using MVC architectural pattern with base utilities, tests, and pipeline to speed up creating new projects based on FastAPI.
As of today [FastAPI](https://fastapi.tiangolo.com/) doesn't have any project generator like other known web frameworks ex: Django, Rails, etc., which makes creating new projects based on it that much more time-consuming.
The idea behind this template is that, one can fork this repo, rename package and then start implementing endpoints logic straightaway, rather than creating the whole project from scratch.
Moreover, the project is structured in MVC architectural pattern to help developers who don't know FastAPI yet but are familiar with MVC to get up to speed quickly.
Last but not least this application utilizes WSGI + ASGI combination for the best performance possible. Mainly because web servers like Nginx don't know how to async and WSGI is single synchronous callable. You can further read about this [here](https://asgi.readthedocs.io/en/latest/introduction.html).
Additionally, here are some benchmarks done by wonderful people of StackOverflow:
* https://stackoverflow.com/a/62977786/10566747
* https://stackoverflow.com/a/63427961/10566747
### Project structure
```
├── build Makefile build scripts
├── fastapi_mvc_template Python project root
│   ├── app FastAPI core implementation
│   │   ├── config FastAPI configuration: routes, variables
│   │   ├── controllers FastAPI controllers
│   │   ├── models FastAPI models
│   │   ├── utils FastAPI utilities: RedisClient, AiohttpClient
│   │   ├── asgi.py FastAPI ASGI node
│   ├── cli Application command line interface implementation
│   ├── version.py Application version
│   └── wsgi.py Application master node: WSGI
├── tests
│   ├── integration Integration test implementation
│   └── unit Unit tests implementation
├── .travis.yml Pipeline definition
├── CHANGELOG.md
├── Dockerfile
├── LICENSE
├── Makefile
├── README.md
├── requirements.txt
├── setup.py
├── TAG
└── tox.ini Tox task automation definitions
```
## Installation
Prerequisites:
* Python 3.7 or later installed [How to install python](https://docs.python-guide.org/starting/installation/)
* Python package index installed. [How to install pip](https://pip.pypa.io/en/stable/installing/)
* Virtualenv
* make
### Using make
```shell
git clone git@github.com:rszamszur/fastapi-mvc-template.git
cd fastapi-mvc-template
# Project will be installed to virtualenv
make venv
```
### From source
```shell
git clone git@github.com:rszamszur/fastapi-mvc-template.git
cd fastapi-mvc-template
pip install .
# Or if you want to have build and test dependencies as well
pip install -r requirements.txt
```
## Usage
This package exposes simple CLI for easier interaction:
```shell
$ fastapi --help
Usage: fastapi [OPTIONS] COMMAND [ARGS]...
FastAPI MVC template CLI root.
Options:
-v, --verbose Enable verbose logging.
--help Show this message and exit.
Commands:
serve FastAPI MVC template CLI serve command.
$ fastapi serve --help
Usage: fastapi serve [OPTIONS]
FastAPI MVC template CLI serve command.
Options:
--host TEXT Host to bind. [default: localhost]
-p, --port INTEGER Port to bind. [default: 8000]
-w, --workers INTEGER RANGE The number of worker processes for handling
requests. [default: 2;1<=x<=8]
--help Show this message and exit.
```
*NOTE: Maximum number of workers may be different in your case, it's limited to `multiprocessing.cpu_count()`*
To serve application simply run:
```shell
$ fastapi serve
```
To confirm it's working:
```shell
$ curl localhost:8080/api/ready
{"status":"ok"}
```
### Using Dockerfile
This package provides Dockerfile for virtualized environment.
*NOTE: Replace podman with docker if it's yours containerization engine.*
```shell
$ make image
$ podman run -dit --name fastapi-mvc-template -p 8000:8000 fastapi-mvc-template:$(cat TAG)
f41e5fa7ffd512aea8f1aad1c12157bf1e66f961aeb707f51993e9ac343f7a4b
$ podman ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
f41e5fa7ffd5 localhost/fastapi-mvc-template:0.1.0 /usr/bin/fastapi ... 2 seconds ago Up 3 seconds ago 0.0.0.0:8000->8000/tcp fastapi-mvc-template
$ curl localhost:8000/api/ready
{"status":"ok"}
```
## Renaming
To your discretion I've provided simple bash script for renaming whole project, although I do not guarantee it will work with all possible names.
It takes two parameters:
1) new project name *NOTE: if your project name contains '-' this script should automatically change '-' to '_' wherever it's needed.*
2) new project url
```shell
#!/usr/bin/env bash
if [ -n "$DEBUG" ]; then
set -x
fi
set -o errexit
set -o nounset
set -o pipefail
if [[ -z "$1" ]]; then
echo "Parameter project name is empty."
exit 1
fi
if [[ -z "$2" ]]; then
echo "Parameter project url is empty."
exit 1
fi
grep -rl "https://github.com/rszamszur/fastapi-mvc-template" | xargs sed -i "s/https:\/\/github.com\/rszamszur\/fastapi-mvc-template/${2//\//\\/}/g"
if [[ $1 == *"-"* ]]; then
mv fastapi_mvc_template ${1//-/_}
grep -rl --exclude-dir=.git fastapi_mvc_template | xargs sed -i "s/fastapi_mvc_template/${1//-/_}/g"
else
mv fastapi_mvc_template $1
grep -rl --exclude-dir=.git fastapi_mvc_template | xargs sed -i "s/fastapi_mvc_template/$1/g"
fi
grep -rl --exclude-dir=.git fastapi-mvc-template | xargs sed -i "s/fastapi-mvc-template/$1/g"
grep -rl --exclude-dir=.git 'FastAPI MVC template' | xargs sed -i "s/FastAPI MVC template/$1/g"
grep -rl --exclude-dir=.git 'Fastapi MVC template' | xargs sed -i "s/FastAPI MVC template/$1/g"
```
*NOTE: Afterwards you may still want to edit some docstrings or descriptions.*
## Development
You can implement your own web routes logic straight away in `.app.controllers.api.v1` submodule. For more information please see [FastAPI documentation](https://fastapi.tiangolo.com/tutorial/).
### Utilities
For your discretion, I've provided some basic utilities:
* RedisClient `.app.utilities.redis`
* AiohttpClient `.app.utilities.aiohttp_client`
They're initialized in `asgi.py` on FastAPI startup event handler:
```python
async def on_startup():
"""Fastapi startup event handler.
Creates AiohttpClient session.
"""
log.debug("Execute FastAPI startup event handler.")
# Initialize utilities for whole FastAPI application without passing object
# instances within the logic. Feel free to disable it if you don't need it.
RedisClient.open_redis_client()
AiohttpClient.get_aiohttp_client()
async def on_shutdown():
"""Fastapi shutdown event handler.
Destroys AiohttpClient session.
"""
log.debug("Execute FastAPI shutdown event handler.")
# Gracefully close utilities.
await RedisClient.close_redis_client()
await AiohttpClient.close_aiohttp_client()
```
and are available for whole application scope without passing object instances. In order to utilize it just execute classmethods directly.
Example:
```python
from fastapi_mvc_template.app.utils.redis import RedisClient
response = RedisClient.get("Key")
```
```python
from fastapi_mvc_template.app.utils.aiohttp_client import AiohttpClient
response = AiohttpClient.get("http://foo.bar")
```
If you don't need it just simply remove the utility, init on start up and tests.
### Application configuration
All application configuration is available under `.app.config` submodule:
Global config:
```python
from fastapi_mvc_template.version import __version__
# FastAPI logging level
DEBUG = True
# FastAPI project name
PROJECT_NAME = "fastapi_mvc_template"
VERSION = __version__
# All your additional application configuration should go either here or in
# separate file in this submodule.
```
Redis config for RedisClient utility (can be overridden by env variables):
```python
import os
REDIS_HOST = os.getenv('FASTAPI_REDIS_HOST', 'localhost')
REDIS_PORT = int(os.getenv('FASTAPI_REDIS_PORT', 6379))
REDIS_USERNAME = os.getenv('FASTAPI_REDIS_USERNAME', None)
REDIS_PASSWORD = os.getenv('FASTAPI_REDIS_PASSWORD', None)
# If provided above Redis config is for Sentinel.
REDIS_USE_SENTINEL = bool(os.getenv('FASTAPI_REDIS_USE_SENTINEL', False))
```
Lastly web routes definition. Just simply import your controller and include it to FastAPI router:
```python
from fastapi import APIRouter
from fastapi_mvc_template.app.controllers.api.v1 import ready
router = APIRouter(
prefix="/api"
)
router.include_router(ready.router, tags=["ready"])
```
### Web Routes
All routes documentation is available on:
* `/` with Swagger
* `/redoc` or ReDoc.
## Contributing
Questions, comments or improvements? Please create an issue on Github. I do my best to include every contribution proposed in any way that I can.
## License
[MIT](https://github.com/rszamszur/fastapi-mvc-template/blob/master/LICENSE)

1
TAG Normal file
View File

@@ -0,0 +1 @@
0.1.0

23
build/clean-image.sh Executable file
View File

@@ -0,0 +1,23 @@
#!/usr/bin/env bash
if [ -n "$DEBUG" ]; then
set -x
fi
set -o errexit
set -o nounset
set -o pipefail
DIR=$(cd $(dirname "${BASH_SOURCE}") && pwd -P)
if command -v docker &> /dev/null; then
echo "[image] Found docker-engine, begin removing image."
docker rmi -f fastapi-mvc-template:$TAG . || true
elif command -v podman &> /dev/null; then
echo "[image] Found podman container engine, begin removing image."
podman rmi -f fastapi-mvc-template:$TAG . || true
else
echo "[image] Neither docker nor podman container engine found."
exit 1
fi

23
build/image.sh Executable file
View File

@@ -0,0 +1,23 @@
#!/usr/bin/env bash
if [ -n "$DEBUG" ]; then
set -x
fi
set -o errexit
set -o nounset
set -o pipefail
DIR=$(cd $(dirname "${BASH_SOURCE}") && pwd -P)
if command -v docker &> /dev/null; then
echo "[image] Found docker-engine, begin building image."
docker build -t fastapi-mvc-template:$TAG .
elif command -v podman &> /dev/null; then
echo "[image] Found podman container engine, begin building image."
podman build -t fastapi-mvc-template:$TAG .
else
echo "[image] Neither docker nor podman container engine found."
exit 1
fi

40
build/venv.sh Executable file
View File

@@ -0,0 +1,40 @@
#!/usr/bin/env bash
if [ -n "$DEBUG" ]; then
set -x
fi
set -o errexit
set -o nounset
set -o pipefail
DIR=$(cd $(dirname "${BASH_SOURCE}") && pwd -P)
if ! command -v python &> /dev/null; then
echo "python is not installed"
exit 1
fi
if ! command -v virtualenv &> /dev/null; then
echo "virtualenv is not installed"
exit 1
fi
PYTHON_VERSION=$(python -V 2>&1 | grep -Eo '([0-9]+\.[0-9]+\.[0-9]+)$')
if [[ ${PYTHON_VERSION} < "3.7.0" ]]; then
echo "Please upgrade python to 3.7.0 or higher"
exit 1
fi
echo "[venv] creating virtualenv"
virtualenv --python=python venv
echo "[venv] installing project"
venv/bin/pip install .
cat <<EOF
Project successfully installed in virtualenv
To activate virtualenv run from project root: $ source venv/bin/activate
Now you should access CLI binary: $ fastapi --help
To deactivate virtualenv simply type: $ deactivate
EOF

View File

@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
"""FastAPI MVC template."""
import logging
from .version import __version__ # noqa: F401
# initialize logging
log = logging.getLogger(__name__)
log.addHandler(logging.NullHandler())

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
"""FastAPI MVC template."""

View File

@@ -0,0 +1,66 @@
# -*- coding: utf-8 -*-
"""Application Asynchronous Server Gateway Interface."""
import logging
from fastapi import FastAPI
from fastapi_mvc_template.app.config.router import router
from fastapi_mvc_template.app.config.application import (
DEBUG,
PROJECT_NAME,
VERSION,
)
from fastapi_mvc_template.app.utils.redis import RedisClient
from fastapi_mvc_template.app.utils.aiohttp_client import AiohttpClient
log = logging.getLogger(__name__)
async def on_startup():
"""Fastapi startup event handler.
Creates AiohttpClient session.
"""
log.debug("Execute FastAPI startup event handler.")
# Initialize utilities for whole FastAPI application without passing object
# instances within the logic. Feel free to disable it if you don't need it.
RedisClient.open_redis_client()
AiohttpClient.get_aiohttp_client()
async def on_shutdown():
"""Fastapi shutdown event handler.
Destroys AiohttpClient session.
"""
log.debug("Execute FastAPI shutdown event handler.")
# Gracefully close utilities.
await RedisClient.close_redis_client()
await AiohttpClient.close_aiohttp_client()
def get_app():
"""Initialize FastAPI application.
Returns:
app (FastAPI): Application object instance.
"""
log.debug("Initialize FastAPI application node.")
app = FastAPI(
title=PROJECT_NAME,
debug=DEBUG,
version=VERSION,
docs_url="/",
on_startup=[on_startup],
on_shutdown=[on_shutdown],
)
log.debug("Add application routes.")
app.include_router(router)
return app
application = get_app()

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
"""FastAPI MVC template."""

View File

@@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
"""Application configuration."""
from fastapi_mvc_template.version import __version__
# FastAPI logging level
DEBUG = True
# FastAPI project name
PROJECT_NAME = "fastapi_mvc_template"
VERSION = __version__
# All your additional application configuration should go either here or in
# separate file in this submodule.

View File

@@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
"""Redis configuration."""
import os
REDIS_HOST = os.getenv('FASTAPI_REDIS_HOST', 'localhost')
REDIS_PORT = int(os.getenv('FASTAPI_REDIS_PORT', 6379))
REDIS_USERNAME = os.getenv('FASTAPI_REDIS_USERNAME', None)
REDIS_PASSWORD = os.getenv('FASTAPI_REDIS_PASSWORD', None)
# If provided above Redis config is for Sentinel.
REDIS_USE_SENTINEL = bool(os.getenv('FASTAPI_REDIS_USE_SENTINEL', False))

View File

@@ -0,0 +1,12 @@
"""Application routes configuration.
In this file all application endpoints are being defined.
"""
from fastapi import APIRouter
from fastapi_mvc_template.app.controllers.api.v1 import ready
router = APIRouter(
prefix="/api"
)
router.include_router(ready.router, tags=["ready"])

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
"""FastAPI MVC template."""

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
"""FastAPI MVC template."""

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
"""FastAPI MVC template."""

View File

@@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
"""Ready controller."""
import logging
from fastapi import APIRouter
from fastapi_mvc_template.app.models.ready import ReadyResponse
router = APIRouter()
log = logging.getLogger(__name__)
@router.get(
"/ready",
tags=["ready"],
response_model=ReadyResponse,
summary="Simple health check."
)
async def readiness_check():
"""Run basic application health check.
If application is up and running then this endpoint will return simple
response with status ok. Otherwise app will be unavailable or an internal
server error will be returned.
\f
Returns:
response (ReadyResponse): ReadyResponse model object instance.
"""
log.info("Started GET /ready")
return ReadyResponse(status="ok")

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
"""FastAPI MVC template."""

View File

@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
"""Ready model."""
from pydantic import BaseModel
class ReadyResponse(BaseModel):
"""Ready response model.
Attributes:
status (str): Strings are accepted as-is, int float and Decimal are
coerced using str(v), bytes and bytearray are converted using
v.decode(), enums inheriting from str are converted using
v.value, and all other types cause an error.
Raises:
pydantic.error_wrappers.ValidationError: If any of provided attribute
doesn't pass type validation.
"""
status: str

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
"""FastAPI MVC template."""

View File

@@ -0,0 +1,107 @@
# -*- coding: utf-8 -*-
"""Aiohttp client class utility."""
import logging
import asyncio
from dataclasses import dataclass
from typing import Optional
from socket import AF_INET
import aiohttp
from pydantic import BaseModel
SIZE_POOL_AIOHTTP = 100
class AiohttpClient(object):
"""Aiohttp session client utility.
Utility class for handling HTTP async request for whole FastAPI application
scope.
Attributes:
sem (asyncio.Semaphore, optional): Semaphore value.
aiohttp_client (aiohttp.ClientSession, optional): Aiohttp client session
object instance.
"""
sem: Optional[asyncio.Semaphore] = None
aiohttp_client: Optional[aiohttp.ClientSession] = None
log: logging.Logger = logging.getLogger(__name__)
@classmethod
def get_aiohttp_client(cls):
"""Create aiohttp client session object instance.
Returns:
aiohttp.ClientSession: ClientSession object instance.
"""
if cls.aiohttp_client is None:
cls.log.debug("Initialize AiohttpClient session.")
timeout = aiohttp.ClientTimeout(total=2)
connector = aiohttp.TCPConnector(
family=AF_INET,
limit_per_host=SIZE_POOL_AIOHTTP,
)
cls.aiohttp_client = aiohttp.ClientSession(
timeout=timeout,
connector=connector,
)
return cls.aiohttp_client
@classmethod
async def close_aiohttp_client(cls):
"""Close aiohttp client session."""
if cls.aiohttp_client:
cls.log.debug("Close AiohttpClient session.")
await cls.aiohttp_client.close()
cls.aiohttp_client = None
@classmethod
async def get(cls, url):
"""Execute HTTP GET request.
Args:
url (str): HTTP GET request endpoint.
Returns:
response: HTTP GET request response. Either aiohttp.ClientResponse
object instance, or if timeout occured returns CustomResponse
object instance.
"""
client = cls.get_aiohttp_client()
try:
cls.log.debug("Started GET {}".format(url))
return await client.get(url)
except asyncio.TimeoutError:
cls.log.warning("Completed 408 Request Timeout")
return CustomResponse(
status=408,
content={"detail": "Request Timeout"}
)
@dataclass(init=False)
class CustomResponse(BaseModel):
"""Custom response model class.
This utility helps to unify responses by keeping object-like attribute
calling for classes and methods which utilize AiohttpClient.
Attributes:
status (int): Uses int(v) to coerce types to an int.
content (dict): dict(v) is used to attempt to convert a dictionary.
Raises:
pydantic.error_wrappers.ValidationError: If any of provided attribute
doesn't pass type validation.
"""
status: int
content: dict

View File

@@ -0,0 +1,273 @@
# -*- coding: utf-8 -*-
"""Redis client class utility."""
import logging
import aioredis
import aioredis.sentinel
from aioredis.exceptions import RedisError
from fastapi_mvc_template.app.config.redis import (
REDIS_HOST,
REDIS_PORT,
REDIS_USERNAME,
REDIS_PASSWORD,
REDIS_USE_SENTINEL
)
class RedisClient(object):
"""Redis client utility.
Utility class for handling Redis database connection and operations.
Attributes:
redis_client (aioredis.Redis, optional): Redis client object instance.
log (logging.Logger): Logging handler for this class.
base_redis_init_kwargs (dict): Common kwargs regardless other Redis
configuration
connection_kwargs (dict, optional): Extra kwargs for Redis object init.
"""
redis_client: aioredis.Redis = None
log: logging.Logger = logging.getLogger(__name__)
base_redis_init_kwargs: dict = {
"encoding": "utf-8",
"port": REDIS_PORT
}
connection_kwargs: dict = {}
@classmethod
def open_redis_client(cls):
"""Create Redis client session object instance.
Based on configuration create either Redis client or Redis Sentinel.
Returns:
aioredis.Redis: Redis object instance.
"""
if cls.redis_client is None:
cls.log.debug("Initialize Redis client.")
if REDIS_USERNAME and REDIS_PASSWORD:
cls.connection_kwargs = {
"username": REDIS_USERNAME,
"password": REDIS_PASSWORD,
}
if REDIS_USE_SENTINEL:
sentinel = aioredis.sentinel.Sentinel(
[(REDIS_HOST, REDIS_PORT)],
sentinel_kwargs=cls.connection_kwargs
)
cls.redis_client = sentinel.master_for("mymaster")
else:
cls.base_redis_init_kwargs.update(cls.connection_kwargs)
cls.redis_client = aioredis.from_url(
"redis://{0:s}".format(REDIS_HOST),
**cls.base_redis_init_kwargs,
)
return cls.redis_client
@classmethod
async def close_redis_client(cls):
"""Close Redis client."""
if cls.redis_client:
cls.log.debug("Closing Redis client")
await cls.redis_client.close()
@classmethod
async def ping(cls):
"""Execute Redis PING command.
Ping the Redis server.
Returns:
response: Boolean, whether Redis client could ping Redis server.
Raises:
aioredis.RedisError: If Redis client failed while executing command.
"""
# Note: Not sure if this shouldn't be deep copy instead?
redis_client = cls.redis_client
cls.log.debug("Preform Redis PING command")
try:
return await redis_client.ping()
except RedisError as ex:
cls.log.exception(
"Redis PING command finished with exception",
exc_info=(type(ex), ex, ex.__traceback__)
)
raise ex
@classmethod
async def set(cls, key, value):
"""Execute Redis SET command.
Set key to hold the string value. If key already holds a value, it is
overwritten, regardless of its type.
Args:
key (str): Redis db key.
value (str): Value to be set.
Returns:
response: Redis SET command response, for more info
look: https://redis.io/commands/set#return-value
Raises:
aioredis.RedisError: If Redis client failed while executing command.
"""
redis_client = cls.redis_client
cls.log.debug(
"Preform Redis SET command, key: {}, value: {}".format(key, value)
)
try:
await redis_client.set(key, value)
except RedisError as ex:
cls.log.exception(
"Redis SET command finished with exception",
exc_info=(type(ex), ex, ex.__traceback__)
)
raise ex
@classmethod
async def rpush(cls, key, value):
"""Execute Redis RPUSH command.
Insert all the specified values at the tail of the list stored at key.
If key does not exist, it is created as empty list before performing
the push operation. When key holds a value that is not a list, an
error is returned.
Args:
key (str): Redis db key.
value (str, list): Single or multiple values to append.
Returns:
response: Length of the list after the push operation.
Raises:
aioredis.RedisError: If Redis client failed while executing command.
"""
redis_client = cls.redis_client
cls.log.debug(
"Preform Redis RPUSH command, key: {}, value: {}".format(key, value)
)
try:
await redis_client.rpush(key, value)
except RedisError as ex:
cls.log.exception(
"Redis RPUSH command finished with exception",
exc_info=(type(ex), ex, ex.__traceback__)
)
raise ex
@classmethod
async def exists(cls, key):
"""Execute Redis EXISTS command.
Returns if key exists.
Args:
key (str): Redis db key.
Returns:
response: Boolean whether key exists in Redis db.
Raises:
aioredis.RedisError: If Redis client failed while executing command.
"""
redis_client = cls.redis_client
cls.log.debug(
"Preform Redis EXISTS command, key: {}, exists".format(key)
)
try:
return await redis_client.exists(key)
except RedisError as ex:
cls.log.exception(
"Redis EXISTS command finished with exception",
exc_info=(type(ex), ex, ex.__traceback__)
)
raise ex
@classmethod
async def get(cls, key):
"""Execute Redis GET command.
Get the value of key. If the key does not exist the special value None
is returned. An error is returned if the value stored at key is not a
string, because GET only handles string values.
Args:
key (str): Redis db key.
Returns:
response: Value of key.
Raises:
aioredis.RedisError: If Redis client failed while executing command.
"""
redis_client = cls.redis_client
cls.log.debug(
"Preform Redis GET command, key: {}".format(key)
)
try:
return await redis_client.get(key)
except RedisError as ex:
cls.log.exception(
"Redis GET command finished with exception",
exc_info=(type(ex), ex, ex.__traceback__)
)
raise ex
@classmethod
async def lrange(cls, key, start, end):
"""Execute Redis LRANGE command.
Returns the specified elements of the list stored at key. The offsets
start and stop are zero-based indexes, with 0 being the first element
of the list (the head of the list), 1 being the next element and so on.
These offsets can also be negative numbers indicating offsets starting
at the end of the list. For example, -1 is the last element of the
list, -2 the penultimate, and so on.
Args:
key (str): Redis db key.
start (int): Start offset value.
end (int): End offset value.
Returns:
response: Returns the specified elements of the list stored at key.
Raises:
aioredis.RedisError: If Redis client failed while executing command.
"""
redis_client = cls.redis_client
cls.log.debug(
"Preform Redis LRANGE command, key: {}, start: {}, end: {}".format(
key,
start,
end,
)
)
try:
return await redis_client.lrange(key, start, end)
except RedisError as ex:
cls.log.exception(
"Redis LRANGE command finished with exception",
exc_info=(type(ex), ex, ex.__traceback__)
)
raise ex

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
"""FastAPI MVC template."""

View File

@@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
"""FastAPI MVC template CLI root."""
import logging
import click
from fastapi_mvc_template.cli.commands.serve import serve
@click.group()
@click.option(
"-v",
"--verbose",
help="Enable verbose logging.",
is_flag=True,
default=False,
)
def cli(**options):
"""Fastapi MVC template CLI root."""
if options['verbose']:
level = logging.DEBUG
else:
level = logging.INFO
logging.basicConfig(
level=level,
format="[%(asctime)s] [%(process)s] [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S %z",
)
cli.add_command(serve)

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
"""FastAPI MVC template."""

View File

@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
"""FastAPI MVC template CLI serve command."""
from multiprocessing import cpu_count
import click
from fastapi_mvc_template.wsgi import run_wsgi
@click.command()
@click.option(
"--host",
help="Host to bind.",
type=click.STRING,
default="localhost",
required=False,
show_default=True,
)
@click.option(
"-p",
"--port",
help="Port to bind.",
type=click.INT,
default=8000,
required=False,
show_default=True,
)
@click.option(
"-w",
"--workers",
help="The number of worker processes for handling requests.",
type=click.IntRange(min=1, max=cpu_count()),
default=2,
required=False,
show_default=True,
)
def serve(**options):
"""Fastapi MVC template CLI serve command."""
run_wsgi(
host=options["host"],
port=str(options["port"]),
workers=str(options["workers"]),
)

View File

@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
"""FastAPI MVC template version."""
__version__ = '0.1.0'

View File

@@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
"""Application Web Server Gateway Interface - gunicorn."""
import os
import sys
import logging
from gunicorn.app.base import Application
from fastapi_mvc_template.app.asgi import get_app
log = logging.getLogger(__name__)
class ApplicationLoader(Application):
"""Bypasses the class `WSGIApplication."""
def init(self, parser, opts, args):
"""Class ApplicationLoader object constructor."""
self.cfg.set("default_proc_name", args[0])
def load(self):
"""Load application."""
return get_app()
def run_wsgi(host, port, workers):
"""Run gunicorn WSGI with ASGI workers."""
log.info("Start gunicorn WSGI with ASGI workers.")
sys.argv = [
"--gunicorn",
"-w",
workers,
"-k",
"uvicorn.workers.UvicornWorker",
"-b {host}:{port}".format(
host=host,
port=port,
),
]
sys.argv.append("fastapi_mvc_template.app.asgi:application")
ApplicationLoader().run()
if __name__ == "__main__":
run_wsgi(
host=os.getenv("APP_HOST", "localhost"),
port=os.getenv("APP_PORT", "5000"),
workers=os.getenv("WORKERS", "4"),
)

25
requirements.txt Normal file
View File

@@ -0,0 +1,25 @@
# project requirements
uvicorn[standard]==0.14.0
fastapi>=0.66.0,<0.67.0
starlette>=0.14.2
pydantic>=1.8.2
gunicorn>=20.1.0,<20.2.0
aioredis==2.0.0a1
aiohttp>=3.7.0,<4.0.0
click>=7.1.2
# build requirements
wheel>=0.36.2
setuptools>=49.1.3
# test requirements
pytest>=6.2.4
pytest-cov>=2.12.0
pytest-asyncio>=0.15.1
requests>=2.25.1
aioresponses>=0.7.2
mock>=4.0.3
flake8>=3.9.2
flake8-docstrings>=1.6.0
flake8-import-order>=0.18.1
flake8-todo>=0.7

44
setup.py Normal file
View File

@@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
"""Setup file configuration."""
from os import path
from setuptools import setup, find_packages
here = path.abspath(path.dirname(__file__))
exec(open('fastapi_mvc_template/version.py').read())
setup(
name='fastapi_mvc_template',
version=__version__, # noqa: F821
description='FastAPI MVC template',
url='https://github.com/rszamszur/fastapi-mvc-template',
author='Radosław Szamszur',
author_email='radoslawszamszur@gmail.com',
classifiers=[
'Development Status :: 3 - Alpha',
'Intended Audience :: Developers',
'Natural Language :: English',
'Topic :: Software Development :: FastAPI MVC template',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9',
],
packages=find_packages(),
install_requires=[
"uvicorn[standard]==0.14.0",
"fastapi>=0.66.0,<0.67.0",
"starlette>=0.14.2",
"pydantic>=1.8.2",
"gunicorn>=20.1.0,<20.2.0",
"aioredis==2.0.0a1",
"aiohttp>=3.7.0,<4.0.0",
"click>=7.1.2",
],
entry_points={
'console_scripts': [
'fastapi=fastapi_mvc_template.cli.cli:cli',
],
},
)

0
tests/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,11 @@
from fastapi.testclient import TestClient
from fastapi_mvc_template.app.asgi import get_app
app = TestClient(get_app())
def test_ready():
response = app.get("/api/ready")
assert response.status_code == 200
assert response.json() == {"status": "ok"}

0
tests/unit/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,9 @@
import pytest
from fastapi.testclient import TestClient
from fastapi_mvc_template.app.asgi import get_app
@pytest.fixture
def app():
app = get_app()
yield TestClient(app)

View File

View File

@@ -0,0 +1,9 @@
def test_ready(app):
response = app.get("/api/ready")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
def test_ready_invalid(app):
response = app.get("/api/ready/123")
assert response.status_code == 404

View File

View File

@@ -0,0 +1,24 @@
import pytest
from fastapi_mvc_template.app.models.ready import ReadyResponse
from pydantic.error_wrappers import ValidationError
@pytest.mark.parametrize("value", [
"ok",
"Another string",
"ąŻŹÐĄŁĘ®ŒĘŚÐ",
15,
False,
])
def test_ready_response(value):
ReadyResponse(status=value)
@pytest.mark.parametrize("value", [
({"status": "ok"}),
([123, "ok"]),
(["ok", "ready"]),
])
def test_ready_response_invalid(value):
with pytest.raises(ValidationError):
ReadyResponse(status=value)

View File

@@ -0,0 +1,16 @@
from fastapi_mvc_template.app.config.application import PROJECT_NAME, VERSION
def test_app_config(app):
assert app.app.title == PROJECT_NAME
assert app.app.version == VERSION
def test_read_main(app):
response = app.get("/")
assert response.status_code == 200
def test_not_found(app):
response = app.get("/some/none/existing/path")
assert response.status_code == 404

View File

View File

@@ -0,0 +1,38 @@
import pytest
import aiohttp
from aioresponses import aioresponses
from fastapi_mvc_template.app.utils.aiohttp_client import AiohttpClient
@pytest.mark.asyncio
async def test_get():
with aioresponses() as mock:
mock.get("http://example.com/api", status=200, payload=dict(time=237))
AiohttpClient.get_aiohttp_client()
response = await AiohttpClient.get("http://example.com/api")
assert response.status == 200
assert await response.json() == {"time": 237}
@pytest.mark.asyncio
async def test_get_timeout():
with aioresponses() as mock:
mock.get("http://example.com/api", timeout=True)
AiohttpClient.get_aiohttp_client()
response = await AiohttpClient.get("http://example.com/api")
assert response.status == 408
assert response.content == {"detail": "Request Timeout"}
@pytest.mark.asyncio
async def test_close_aiohttp_client():
await AiohttpClient.close_aiohttp_client()
assert AiohttpClient.aiohttp_client is None
@pytest.mark.asyncio
async def test_get_aiohttp_client():
AiohttpClient.get_aiohttp_client()
assert isinstance(AiohttpClient.aiohttp_client, aiohttp.ClientSession)

View File

@@ -0,0 +1,59 @@
import mock
import pytest
from aioredis import Redis
from fastapi_mvc_template.app.utils.redis import RedisClient
# monkey patch for allowing MagicMock to be used with await
# https://stackoverflow.com/a/51399767/10566747
async def async_magic():
pass
mock.MagicMock.__await__ = lambda x: async_magic().__await__()
def test_open_redis_client():
RedisClient.open_redis_client()
assert isinstance(RedisClient.redis_client, Redis)
@pytest.mark.asyncio
async def test_ping():
RedisClient.redis_client = mock.MagicMock()
await RedisClient.ping()
RedisClient.redis_client.ping.assert_called_once()
@pytest.mark.asyncio
async def test_set():
RedisClient.redis_client = mock.MagicMock()
await RedisClient.set("key", "value")
RedisClient.redis_client.set.assert_called_once_with("key", "value")
@pytest.mark.asyncio
async def test_rpush():
RedisClient.redis_client = mock.MagicMock()
await RedisClient.rpush("key", "value")
RedisClient.redis_client.rpush.assert_called_once_with("key", "value")
@pytest.mark.asyncio
async def test_exists():
RedisClient.redis_client = mock.MagicMock()
await RedisClient.exists("key")
RedisClient.redis_client.exists.assert_called_once_with("key")
@pytest.mark.asyncio
async def test_get():
RedisClient.redis_client = mock.MagicMock()
await RedisClient.get("key")
RedisClient.redis_client.get.assert_called_once_with("key")
@pytest.mark.asyncio
async def test_lrange():
RedisClient.redis_client = mock.MagicMock()
await RedisClient.lrange("key", 1, -1)
RedisClient.redis_client.lrange.assert_called_once_with("key", 1, -1)

View File

View File

View File

@@ -0,0 +1,11 @@
from fastapi_mvc_template.cli.commands.serve import serve
def test_root_help(cli_runner):
result = cli_runner.invoke(serve, ["--help"])
assert result.exit_code == 0
def test_root_invalid_option(cli_runner):
result = cli_runner.invoke(serve, ["--not_exists"])
assert result.exit_code == 2

View File

@@ -0,0 +1,7 @@
import pytest
from click.testing import CliRunner
@pytest.fixture
def cli_runner():
yield CliRunner()

View File

@@ -0,0 +1,16 @@
from fastapi_mvc_template.cli.cli import cli
def test_root(cli_runner):
result = cli_runner.invoke(cli)
assert result.exit_code == 0
def test_root_help(cli_runner):
result = cli_runner.invoke(cli, ["--help"])
assert result.exit_code == 0
def test_root_invalid_option(cli_runner):
result = cli_runner.invoke(cli, ["--not_exists"])
assert result.exit_code == 2