mirror of
https://github.com/rszamszur/fastapi-mvc-template.git
synced 2021-11-08 01:34:05 +03:00
23
.github/workflows/build.yml
vendored
Normal file
23
.github/workflows/build.yml
vendored
Normal 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
76
.github/workflows/test.yml
vendored
Normal 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
33
.gitignore
vendored
Normal 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
7
CHANGELOG.md
Normal 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
13
Dockerfile
Normal 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
21
LICENSE
Normal 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
35
Makefile
Normal 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
288
README.md
@@ -1,2 +1,288 @@
|
||||
# fastapi-mvc-template
|
||||
# FastAPI-MVC-template
|
||||
|
||||

|
||||

|
||||
[](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)
|
||||
|
||||
23
build/clean-image.sh
Executable file
23
build/clean-image.sh
Executable 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
23
build/image.sh
Executable 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
40
build/venv.sh
Executable 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
|
||||
9
fastapi_mvc_template/__init__.py
Normal file
9
fastapi_mvc_template/__init__.py
Normal 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())
|
||||
2
fastapi_mvc_template/app/__init__.py
Normal file
2
fastapi_mvc_template/app/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""FastAPI MVC template."""
|
||||
66
fastapi_mvc_template/app/asgi.py
Normal file
66
fastapi_mvc_template/app/asgi.py
Normal 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()
|
||||
2
fastapi_mvc_template/app/config/__init__.py
Normal file
2
fastapi_mvc_template/app/config/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""FastAPI MVC template."""
|
||||
12
fastapi_mvc_template/app/config/application.py
Normal file
12
fastapi_mvc_template/app/config/application.py
Normal 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.
|
||||
11
fastapi_mvc_template/app/config/redis.py
Normal file
11
fastapi_mvc_template/app/config/redis.py
Normal 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))
|
||||
12
fastapi_mvc_template/app/config/router.py
Normal file
12
fastapi_mvc_template/app/config/router.py
Normal 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"])
|
||||
2
fastapi_mvc_template/app/controllers/__init__.py
Normal file
2
fastapi_mvc_template/app/controllers/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""FastAPI MVC template."""
|
||||
2
fastapi_mvc_template/app/controllers/api/__init__.py
Normal file
2
fastapi_mvc_template/app/controllers/api/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""FastAPI MVC template."""
|
||||
2
fastapi_mvc_template/app/controllers/api/v1/__init__.py
Normal file
2
fastapi_mvc_template/app/controllers/api/v1/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""FastAPI MVC template."""
|
||||
31
fastapi_mvc_template/app/controllers/api/v1/ready.py
Normal file
31
fastapi_mvc_template/app/controllers/api/v1/ready.py
Normal 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")
|
||||
2
fastapi_mvc_template/app/models/__init__.py
Normal file
2
fastapi_mvc_template/app/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""FastAPI MVC template."""
|
||||
21
fastapi_mvc_template/app/models/ready.py
Normal file
21
fastapi_mvc_template/app/models/ready.py
Normal 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
|
||||
2
fastapi_mvc_template/app/utils/__init__.py
Normal file
2
fastapi_mvc_template/app/utils/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""FastAPI MVC template."""
|
||||
107
fastapi_mvc_template/app/utils/aiohttp_client.py
Normal file
107
fastapi_mvc_template/app/utils/aiohttp_client.py
Normal 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
|
||||
273
fastapi_mvc_template/app/utils/redis.py
Normal file
273
fastapi_mvc_template/app/utils/redis.py
Normal 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
|
||||
2
fastapi_mvc_template/cli/__init__.py
Normal file
2
fastapi_mvc_template/cli/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""FastAPI MVC template."""
|
||||
31
fastapi_mvc_template/cli/cli.py
Normal file
31
fastapi_mvc_template/cli/cli.py
Normal 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)
|
||||
2
fastapi_mvc_template/cli/commands/__init__.py
Normal file
2
fastapi_mvc_template/cli/commands/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""FastAPI MVC template."""
|
||||
42
fastapi_mvc_template/cli/commands/serve.py
Normal file
42
fastapi_mvc_template/cli/commands/serve.py
Normal 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"]),
|
||||
)
|
||||
3
fastapi_mvc_template/version.py
Normal file
3
fastapi_mvc_template/version.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""FastAPI MVC template version."""
|
||||
__version__ = '0.1.0'
|
||||
50
fastapi_mvc_template/wsgi.py
Normal file
50
fastapi_mvc_template/wsgi.py
Normal 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
25
requirements.txt
Normal 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
44
setup.py
Normal 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
0
tests/__init__.py
Normal file
0
tests/integration/__init__.py
Normal file
0
tests/integration/__init__.py
Normal file
11
tests/integration/test_ready_endpoint.py
Normal file
11
tests/integration/test_ready_endpoint.py
Normal 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
0
tests/unit/__init__.py
Normal file
0
tests/unit/app/__init__.py
Normal file
0
tests/unit/app/__init__.py
Normal file
9
tests/unit/app/conftest.py
Normal file
9
tests/unit/app/conftest.py
Normal 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)
|
||||
0
tests/unit/app/controllers/__init__.py
Normal file
0
tests/unit/app/controllers/__init__.py
Normal file
0
tests/unit/app/controllers/api/__init__.py
Normal file
0
tests/unit/app/controllers/api/__init__.py
Normal file
0
tests/unit/app/controllers/api/vi/__init__.py
Normal file
0
tests/unit/app/controllers/api/vi/__init__.py
Normal file
9
tests/unit/app/controllers/api/vi/test_ready.py
Normal file
9
tests/unit/app/controllers/api/vi/test_ready.py
Normal 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
|
||||
0
tests/unit/app/models/__init__.py
Normal file
0
tests/unit/app/models/__init__.py
Normal file
24
tests/unit/app/models/test_ready.py
Normal file
24
tests/unit/app/models/test_ready.py
Normal 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)
|
||||
16
tests/unit/app/test_asgi.py
Normal file
16
tests/unit/app/test_asgi.py
Normal 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
|
||||
0
tests/unit/app/utils/__init__.py
Normal file
0
tests/unit/app/utils/__init__.py
Normal file
38
tests/unit/app/utils/test_aiohttp_client.py
Normal file
38
tests/unit/app/utils/test_aiohttp_client.py
Normal 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)
|
||||
59
tests/unit/app/utils/test_redis.py
Normal file
59
tests/unit/app/utils/test_redis.py
Normal 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)
|
||||
0
tests/unit/cli/__init__.py
Normal file
0
tests/unit/cli/__init__.py
Normal file
0
tests/unit/cli/commands/__init__.py
Normal file
0
tests/unit/cli/commands/__init__.py
Normal file
11
tests/unit/cli/commands/test_serve.py
Normal file
11
tests/unit/cli/commands/test_serve.py
Normal 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
|
||||
7
tests/unit/cli/conftest.py
Normal file
7
tests/unit/cli/conftest.py
Normal file
@@ -0,0 +1,7 @@
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cli_runner():
|
||||
yield CliRunner()
|
||||
16
tests/unit/cli/test_cli.py
Normal file
16
tests/unit/cli/test_cli.py
Normal 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
|
||||
Reference in New Issue
Block a user