├── .dockerignore ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── custom.md │ └── feature_request.md └── workflows │ └── mypy_and_tests.yaml ├── .gitignore ├── LICENSE ├── README.MD ├── alembic.ini ├── deployment ├── backups │ ├── Dockerfile │ └── dump-database.sh ├── deploy.sh ├── docker-compose.yml ├── fastapi.Dockerfile └── k8s │ ├── api-config.yaml │ ├── api-deployment.yaml │ ├── api-secret.yaml │ ├── api-service.yaml │ ├── database │ └── backups │ │ ├── backupOn2021-08-24-15-16.dump │ │ └── backupOn2021-08-24-15-18.dump │ ├── postgresql-backup.yaml │ ├── postgresql-deployment.yaml │ ├── postgresql-secret.yaml │ ├── postgresql-service.yaml │ └── postgresql-volumes.yaml ├── mypy.ini ├── poetry.lock ├── pyproject.toml ├── src ├── __init__.py ├── __main__.py ├── api │ ├── __init__.py │ └── v1 │ │ ├── __init__.py │ │ ├── dependencies │ │ ├── __init__.py │ │ ├── database.py │ │ └── services.py │ │ ├── dto.py │ │ ├── endpoints │ │ ├── __init__.py │ │ ├── basic.py │ │ ├── healthcheck.py │ │ ├── oauth.py │ │ ├── products.py │ │ └── users.py │ │ ├── errors │ │ ├── __init__.py │ │ ├── http_error.py │ │ └── validation_error.py │ │ └── not_for_production.py ├── config │ ├── __init__.py │ ├── config.py │ ├── config.yaml │ └── secrets.env ├── core │ ├── __init__.py │ └── events.py ├── middlewares │ ├── __init__.py │ └── process_time_middleware.py ├── resources │ ├── __init__.py │ └── api_string_templates.py ├── services │ ├── __init__.py │ ├── amqp │ │ ├── __init__.py │ │ ├── mailing.py │ │ └── rpc.py │ ├── database │ │ ├── __init__.py │ │ ├── exceptions.py │ │ ├── migrations │ │ │ ├── README │ │ │ ├── __init__.py │ │ │ ├── env.py │ │ │ ├── script.py.mako │ │ │ └── versions │ │ │ │ ├── 799986945827_first_migration.py │ │ │ │ └── __init__.py │ │ ├── models │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── order.py │ │ │ ├── product.py │ │ │ └── user.py │ │ └── repositories │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── product_repository.py │ │ │ └── user_repository.py │ └── security │ │ ├── __init__.py │ │ ├── jwt_service.py │ │ └── oauth.py ├── templates │ └── home │ │ └── index.html ├── utils │ ├── __init__.py │ ├── application_builder │ │ ├── __init__.py │ │ ├── api_installation.py │ │ └── builder_base.py │ ├── database_utils.py │ ├── endpoints_specs.py │ ├── exceptions.py │ ├── gunicorn_app.py │ ├── logging.py │ ├── password_hashing │ │ ├── __init__.py │ │ └── protocol.py │ └── responses.py └── views │ ├── __init__.py │ └── home.py └── tests ├── __init__.py ├── conftest.py ├── test_api ├── __init__.py ├── test_oauth.py ├── test_products.py └── test_users.py └── test_services └── __init__.py /.dockerignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.log 3 | .git* 4 | tests 5 | .pytest_cache 6 | .mypy_cache 7 | .env 8 | deployment 9 | .idea -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Custom issue template 3 | about: Describe this issue template's purpose here. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/mypy_and_tests.yaml: -------------------------------------------------------------------------------- 1 | name: testing workflow 2 | 3 | on: 4 | push: 5 | branches: [ dev ] 6 | pull_request: 7 | branches: [ dev ] 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | env: 13 | COVERAGE_SINGLE: 60 14 | COVERAGE_TOTAL: 60 15 | services: 16 | postgres: 17 | image: postgres:12-alpine 18 | env: 19 | POSTGRES_USER: postgres 20 | POSTGRES_PASSWORD: postgres 21 | POSTGRES_DB: postgres 22 | ports: 23 | - 5432:5432 24 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 25 | 26 | 27 | steps: 28 | - uses: actions/checkout@v2 29 | - name: Set up Python 3.9 30 | uses: actions/setup-python@v2 31 | with: 32 | python-version: 3.9 33 | #---------------------------------------------- 34 | # ----- install & configure poetry ----- 35 | #---------------------------------------------- 36 | - name: Install Poetry 37 | uses: snok/install-poetry@v1 38 | with: 39 | virtualenvs-create: true 40 | virtualenvs-in-project: true 41 | #---------------------------------------------- 42 | # load cached venv if cache exists 43 | #---------------------------------------------- 44 | - name: Load cached venv 45 | id: cached-poetry-dependencies 46 | uses: actions/cache@v2 47 | with: 48 | path: .venv 49 | key: venv-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }} 50 | 51 | #---------------------------------------------- 52 | # install dependencies if cache does not exist 53 | #---------------------------------------------- 54 | - name: Install dependencies 55 | if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' 56 | run: poetry install --no-interaction --no-root 57 | #---------------------------------------------- 58 | # install your root project, if required 59 | #---------------------------------------------- 60 | - name: Install library 61 | run: poetry install --no-interaction 62 | #---------------------------------------------- 63 | # Lint with mypy 64 | #---------------------------------------------- 65 | - name: Mypy check 66 | run: | 67 | poetry run mypy --config-file mypy.ini . 68 | #---------------------------------------------- 69 | # run test suite 70 | #---------------------------------------------- 71 | - name: Run tests 72 | run: | 73 | poetry run pytest . 74 | env: 75 | POSTGRES_USER: postgres 76 | POSTGRES_DB: postgres 77 | POSTGRES_HOST: localhost 78 | POSTGRES_PASSWORD: postgres 79 | # FastAPI config 80 | APP_NAME: FastAPI 81 | API_VERSION: 0.0.1 82 | DOCS_URL: /docs 83 | REDOC_URL: /redoc 84 | DEFAULT_OPEN_API_ROOT: /openapi.json 85 | IS_PRODUCTION: False 86 | # Redis config 87 | REDIS_HOST: localhost 88 | REDIS_PASSWORD: password -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | test.* 9 | src/dev.py 10 | 11 | # test files 12 | src/dev.py 13 | logs 14 | 15 | # Distribution / packaging 16 | .Python 17 | build/ 18 | develop-eggs/ 19 | dist/ 20 | downloads/ 21 | eggs/ 22 | .eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | MANIFEST 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | 56 | # Translations 57 | *.mo 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # pyenv 81 | .python-version 82 | 83 | # celery beat schedule file 84 | celerybeat-schedule 85 | 86 | # SageMath parsed files 87 | *.sage.py 88 | 89 | # Environments 90 | .env 91 | .venv 92 | env/ 93 | venv/ 94 | ENV/ 95 | env.bak/ 96 | venv.bak/ 97 | 98 | # Spyder project settings 99 | .spyderproject 100 | .spyproject 101 | 102 | # Rope project settings 103 | .ropeproject 104 | 105 | # mkdocs documentation 106 | /site 107 | 108 | # mypy 109 | .mypy_cache/ 110 | 111 | secret.py 112 | config_text.py 113 | .idea/ 114 | config_test.py -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Gleb 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | # Asgi application provided by FastAPI 2 | 3 | ![test_workflow](https://github.com/GLEF1X/fastapi-project/actions/workflows/mypy_and_tests.yaml/badge.svg) 4 | 5 | **If you want to check how to implement pure "clean architecture", you can visit another [github repo](https://github.com/GLEF1X/blacksheep-clean-architecture)** 6 | 7 | ### What has already been done?🧭 8 | 9 | - Deployment using Docker and docker-compose 10 | - Deployment using kubernetes(with backups by cron, volumes for database) 11 | - Tests for API routes and JWT helper funcs using pytest 12 | - Integration with `github actions` to provide automatic execution of tests and mypy lint 13 | 14 | 15 | ### TODO: 16 | 17 | - Make own small dependency injector 18 | - Finish up OAuth2 authorization 19 | - Make tests for all routes and endpoints 20 | - Provide Docker & Docker-compose compability with CD 21 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | [alembic] 2 | script_location = ./src/services/database/migrations 3 | 4 | prepend_sys_path = . 5 | 6 | sqlalchemy.url = driver://user:pass@localhost/dbname 7 | 8 | [loggers] 9 | keys = root,sqlalchemy,alembic 10 | 11 | [handlers] 12 | keys = console 13 | 14 | [formatters] 15 | keys = generic 16 | 17 | [logger_root] 18 | level = WARN 19 | handlers = console 20 | qualname = 21 | 22 | [logger_sqlalchemy] 23 | level = WARN 24 | handlers = 25 | qualname = sqlalchemy.engine 26 | 27 | [logger_alembic] 28 | level = INFO 29 | handlers = 30 | qualname = alembic 31 | 32 | [handler_console] 33 | class = StreamHandler 34 | args = (sys.stderr,) 35 | level = NOTSET 36 | formatter = generic 37 | 38 | [formatter_generic] 39 | format = %(levelname)-5.5s [%(name)s] %(message)s 40 | datefmt = %H:%M:%S 41 | -------------------------------------------------------------------------------- /deployment/backups/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.14 2 | 3 | RUN apk update 4 | RUN apk add postgresql 5 | 6 | COPY dump-database.sh . 7 | 8 | ENTRYPOINT [ "/bin/sh" ] 9 | CMD [ "./dump-database.sh" ] -------------------------------------------------------------------------------- /deployment/backups/dump-database.sh: -------------------------------------------------------------------------------- 1 | # shellcheck disable=SC2006 2 | DUMP_FILE_NAME="backupOn$(date +%Y-%m-%d-%H-%M).dump" 3 | echo "Creating dump: $DUMP_FILE_NAME" 4 | 5 | # shellcheck disable=SC2164 6 | cd var/backups 7 | 8 | export PGPASSWORD="$POSTGRES_PASSWORD" 9 | 10 | pg_dump -C --dbname="$POSTGRES_DB" --host="$POSTGRES_HOST" --username="$POSTGRES_USER" --format=c --blobs >"$DUMP_FILE_NAME" 11 | 12 | # shellcheck disable=SC2181 13 | if [ $? -ne 0 ]; then 14 | rm "$DUMP_FILE_NAME" 15 | echo "Back up not created, check db connection settings" 16 | exit 1 17 | fi 18 | 19 | echo 'Successfully Backed Up' 20 | exit 0 21 | -------------------------------------------------------------------------------- /deployment/deploy.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env sh 2 | 3 | # Exit in case of error 4 | set -e 5 | 6 | docker-compose \ 7 | -f deployment/docker-compose.yml -------------------------------------------------------------------------------- /deployment/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | 4 | services: 5 | fastapi: 6 | container_name: fastapi-backend 7 | build: 8 | context: .. 9 | dockerfile: deployment/fastapi.Dockerfile 10 | args: 11 | INSTALL_DEV: ${INSTALL_DEV:-false} 12 | restart: always 13 | depends_on: 14 | - pg_database 15 | - rabbitmq 16 | networks: 17 | - backend 18 | command: 19 | /bin/sh -c "alembic upgrade head && python -m src" 20 | ports: 21 | - "80:8080" 22 | env_file: 23 | - ../.env 24 | 25 | pgadmin: 26 | image: dpage/pgadmin4 27 | depends_on: 28 | - pg_database 29 | container_name: pgadmin-client 30 | env_file: ../.env 31 | restart: always 32 | ports: 33 | - "5050:80" 34 | 35 | pg_database: 36 | container_name: ${DB_CONTAINER_NAME:-postgres} 37 | image: postgres:14-alpine 38 | restart: always 39 | volumes: 40 | - pgdata:/var/lib/postgresql/data 41 | env_file: 42 | - "../.env" 43 | logging: 44 | driver: "json-file" 45 | options: 46 | max-size: 10m 47 | max-file: "3" 48 | labels: "dev_status" 49 | env: "os" 50 | environment: 51 | - os=ubuntu 52 | healthcheck: 53 | test: [ "CMD-SHELL", "pg_isready -U ${DB_USER}" ] 54 | interval: 1s 55 | timeout: 5s 56 | retries: 5 57 | networks: 58 | - backend 59 | expose: 60 | - 5432 61 | 62 | rabbitmq: 63 | container_name: rabbitmq-broker 64 | image: rabbitmq:management-alpine 65 | restart: on-failure 66 | env_file: 67 | - "../.env" 68 | stop_signal: SIGINT 69 | logging: 70 | driver: "json-file" 71 | options: 72 | max-size: 10m 73 | max-file: "3" 74 | labels: "dev_status" 75 | env: "os" 76 | healthcheck: 77 | test: [ "CMD", "rabbitmqctl", "status"] 78 | interval: 5s 79 | timeout: 20s 80 | retries: 5 81 | environment: 82 | - os=ubuntu 83 | ports: 84 | - "32121:15672" 85 | - "5672:5672" 86 | networks: 87 | - backend 88 | 89 | volumes: 90 | pgdata: 91 | name: postgres-data 92 | 93 | networks: 94 | backend: 95 | driver: bridge 96 | 97 | -------------------------------------------------------------------------------- /deployment/fastapi.Dockerfile: -------------------------------------------------------------------------------- 1 | # `python-base` sets up all our shared environment variables 2 | FROM python:3.9.8-buster as python-base 3 | 4 | # python 5 | ENV PYTHONUNBUFFERED=1 \ 6 | # prevents python creating .pyc files 7 | PYTHONDONTWRITEBYTECODE=1 \ 8 | \ 9 | # pip 10 | PIP_NO_CACHE_DIR=off \ 11 | PIP_DISABLE_PIP_VERSION_CHECK=on \ 12 | PIP_DEFAULT_TIMEOUT=100 \ 13 | \ 14 | # poetry 15 | # https://python-poetry.org/docs/configuration/#using-environment-variables 16 | POETRY_VERSION=1.1.12 \ 17 | # make poetry install to this location 18 | POETRY_HOME="/opt/poetry" \ 19 | # make poetry create the virtual environment in the project's root 20 | # it gets named `.venv` 21 | POETRY_VIRTUALENVS_IN_PROJECT=true \ 22 | # do not ask any interactive question 23 | POETRY_NO_INTERACTION=1 \ 24 | \ 25 | # paths 26 | # this is where our requirements + virtual environment will live 27 | PYSETUP_PATH="/opt/pysetup" \ 28 | VENV_PATH="/opt/pysetup/.venv"\ 29 | # cache 30 | PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 31 | 32 | 33 | 34 | # prepend poetry and venv to path 35 | ENV PATH="$POETRY_HOME/bin:$VENV_PATH/bin:$PATH" 36 | 37 | # `builder-base` stage is used to build deps + create our virtual environment 38 | FROM python-base as builder-base 39 | 40 | SHELL ["/bin/bash", "-o", "pipefail", "-c"] 41 | RUN apt-get update \ 42 | && apt-get install --no-install-recommends -y \ 43 | # deps for installing poetry 44 | curl \ 45 | # deps for building python deps 46 | build-essential \ 47 | && apt-get install -y --no-install-recommends build-essential gcc git && apt-get clean \ 48 | && rm -rf /var/lib/apt/lists/* 49 | 50 | # with_changed_query_model poetry - respects $POETRY_VERSION & $POETRY_HOME 51 | RUN curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python - 52 | 53 | # copy project requirement files here to ensure they will be cached. 54 | WORKDIR $PYSETUP_PATH 55 | COPY ./poetry.lock ./pyproject.toml ./ 56 | 57 | # update poetry and with_changed_query_model runtime deps - uses $POETRY_VIRTUALENVS_IN_PROJECT internally 58 | RUN poetry self update && poetry install --no-dev 59 | 60 | 61 | # Прод-образ, куда копируются все собранные ранее зависимости 62 | FROM builder-base as production 63 | # create the app user 64 | RUN addgroup --system app && adduser --system --group app 65 | WORKDIR $PYSETUP_PATH 66 | # copy in our built poetry + venv 67 | COPY --from=builder-base $POETRY_HOME $POETRY_HOME 68 | COPY --from=builder-base $PYSETUP_PATH $PYSETUP_PATH 69 | 70 | # quicker install as runtime deps are already installed 71 | RUN poetry install 72 | 73 | # chown all the files to the app user 74 | 75 | ENV WORKDIR=/bot 76 | WORKDIR $WORKDIR 77 | ENV PATH="/opt/venv/bin:$PATH" 78 | COPY . $WORKDIR 79 | 80 | RUN chown -R app:app $WORKDIR 81 | -------------------------------------------------------------------------------- /deployment/k8s/api-config.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: fastapi-config 5 | data: 6 | APP_NAME: FastAPI 7 | API_VERSION: "0.0.1" 8 | DOCS_URL: "/docs" 9 | REDOC_URL: "/redoc" 10 | DEFAULT_OPEN_API_ROOT: "/openapi.json" 11 | IS_PRODUCTION: "false" 12 | REDIS_HOST: localhost 13 | REDIS_PASSWORD: password -------------------------------------------------------------------------------- /deployment/k8s/api-deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: fastapi 5 | labels: 6 | app: backend-fastapi 7 | spec: 8 | selector: 9 | matchExpressions: 10 | - key: app 11 | operator: In 12 | values: 13 | - fastapi 14 | - key: framework 15 | operator: In 16 | values: 17 | - fastapi 18 | replicas: 1 19 | template: 20 | metadata: 21 | labels: 22 | app: fastapi 23 | framework: fastapi 24 | spec: 25 | containers: 26 | - name: fastapi-backend 27 | image: 90956565/fastapi-test-app:latest 28 | imagePullPolicy: Always 29 | ports: 30 | - containerPort: 8080 31 | protocol: 'TCP' 32 | envFrom: 33 | - secretRef: 34 | name: fastapi-secret 35 | - configMapRef: 36 | name: fastapi-config 37 | - secretRef: 38 | name: postgresql-secret 39 | env: 40 | - name: POSTGRES_HOST 41 | value: 'postgresql-service.test' 42 | livenessProbe: 43 | httpGet: 44 | port: 8080 45 | path: /api/v1/healthcheck 46 | timeoutSeconds: 5 47 | command: [ "sh", "-c", "alembic upgrade head && python -m src" ] 48 | 49 | -------------------------------------------------------------------------------- /deployment/k8s/api-secret.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Secret 3 | metadata: 4 | name: fastapi-secret 5 | type: Opaque 6 | data: 7 | QIWI_SECRET: c29tZV9hcGlfdG9rZW4K 8 | QIWI_API_TOKEN: c29tZV9hcGlfdG9rZW4K 9 | PHONE_NUMBER: K251bWJlcgo= 10 | GOOGLE_CLIENT_ID: c29tZV9jbGllbnRfaWQK 11 | GOOGLE_CLIENT_SECRET: c29tZV9zZWNyZXQK -------------------------------------------------------------------------------- /deployment/k8s/api-service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: fastapi-service 5 | spec: 6 | selector: 7 | framework: fastapi 8 | type: LoadBalancer 9 | ports: 10 | - protocol: 'TCP' 11 | port: 80 12 | targetPort: 8080 # inside of the container -------------------------------------------------------------------------------- /deployment/k8s/database/backups/backupOn2021-08-24-15-16.dump: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/fastapi-project/21a4ad9835936d0dcf9de4d63ae1bed31604a6c8/deployment/k8s/database/backups/backupOn2021-08-24-15-16.dump -------------------------------------------------------------------------------- /deployment/k8s/database/backups/backupOn2021-08-24-15-18.dump: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/fastapi-project/21a4ad9835936d0dcf9de4d63ae1bed31604a6c8/deployment/k8s/database/backups/backupOn2021-08-24-15-18.dump -------------------------------------------------------------------------------- /deployment/k8s/postgresql-backup.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: batch/v1 2 | kind: CronJob 3 | metadata: 4 | name: postgres-backup 5 | spec: 6 | # Backup the database at 2 PM 7 | schedule: "0 2 * * *" 8 | jobTemplate: 9 | spec: 10 | template: 11 | metadata: 12 | name: postgres-backup-worker 13 | spec: 14 | containers: 15 | - name: postgres-backup 16 | image: 90956565/postgresql-backup:1.4-beta 17 | imagePullPolicy: Always 18 | envFrom: 19 | - secretRef: 20 | name: postgresql-secret 21 | env: 22 | - name: POSTGRES_HOST 23 | value: 'postgresql-service.test' 24 | volumeMounts: 25 | - mountPath: /var/backups 26 | name: postgres-backup-volume 27 | restartPolicy: Never 28 | volumes: 29 | - name: postgres-backup-volume 30 | persistentVolumeClaim: 31 | claimName: postgres-backup-pvc 32 | 33 | -------------------------------------------------------------------------------- /deployment/k8s/postgresql-deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: postgresql-database 5 | labels: 6 | app: database 7 | spec: 8 | selector: 9 | matchExpressions: 10 | - key: spec 11 | operator: In 12 | values: [ database, postgres, postgresql ] 13 | replicas: 1 14 | template: 15 | metadata: 16 | labels: 17 | spec: database 18 | spec: 19 | containers: 20 | - name: postgresql-db 21 | image: postgres:12-alpine 22 | imagePullPolicy: Always 23 | envFrom: 24 | - secretRef: 25 | name: postgresql-secret 26 | volumeMounts: 27 | - mountPath: /var/lib/postgresql/data/pgdata 28 | name: postgres-volume 29 | volumes: 30 | - name: postgres-volume 31 | persistentVolumeClaim: 32 | claimName: postgres-pvc -------------------------------------------------------------------------------- /deployment/k8s/postgresql-secret.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Secret 3 | metadata: 4 | name: postgresql-secret 5 | type: Opaque 6 | data: 7 | POSTGRES_USER: cG9zdGdyZXM= 8 | POSTGRES_DB: bmV3X2Ri 9 | POSTGRES_PASSWORD: cG9zdGdyZXM= 10 | POSTGRES_HOST: bG9jYWxob3N0 11 | stringData: 12 | PGDATA: /var/lib/postgresql/data/pgdata -------------------------------------------------------------------------------- /deployment/k8s/postgresql-service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: postgresql-service 5 | spec: 6 | type: LoadBalancer 7 | selector: 8 | spec: database 9 | ports: 10 | - port: 5432 11 | targetPort: 5432 12 | protocol: 'TCP' -------------------------------------------------------------------------------- /deployment/k8s/postgresql-volumes.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: PersistentVolume 3 | metadata: 4 | name: postgres-pv 5 | spec: 6 | storageClassName: standard 7 | volumeMode: Filesystem 8 | capacity: 9 | storage: 4Gi 10 | accessModes: 11 | - ReadWriteOnce 12 | persistentVolumeReclaimPolicy: Delete 13 | hostPath: 14 | path: /database/pgdata 15 | type: DirectoryOrCreate 16 | 17 | 18 | --- 19 | 20 | apiVersion: v1 21 | kind: PersistentVolumeClaim 22 | metadata: 23 | name: postgres-pvc 24 | spec: 25 | volumeName: postgres-pv 26 | storageClassName: standard 27 | accessModes: 28 | - ReadWriteOnce 29 | resources: 30 | requests: 31 | storage: 1Gi 32 | 33 | --- 34 | 35 | # PV for backups 36 | 37 | apiVersion: v1 38 | kind: PersistentVolume 39 | metadata: 40 | name: postgres-backup-pv 41 | spec: 42 | persistentVolumeReclaimPolicy: Retain 43 | storageClassName: standard 44 | capacity: 45 | storage: 4Gi 46 | accessModes: 47 | - ReadWriteOnce 48 | volumeMode: Filesystem 49 | hostPath: 50 | path: /database/backups 51 | type: DirectoryOrCreate 52 | 53 | --- 54 | 55 | # PVC for backups 56 | 57 | apiVersion: v1 58 | kind: PersistentVolumeClaim 59 | metadata: 60 | name: postgres-backup-pvc 61 | spec: 62 | volumeName: postgres-backup-pv 63 | storageClassName: standard 64 | accessModes: 65 | - ReadWriteOnce 66 | resources: 67 | requests: 68 | storage: 1Gi -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | warn_redundant_casts = True 3 | warn_unused_ignores = False 4 | plugins = sqlalchemy.ext.mypy.plugin, pydantic.mypy 5 | ignore_missing_imports = True 6 | # Needed because of bug in MyPy 7 | disallow_subclassing_any = False 8 | pretty = True 9 | 10 | [mypy-*] 11 | disallow_untyped_calls = True 12 | disallow_untyped_defs = True 13 | check_untyped_defs = True 14 | warn_return_any = True 15 | no_implicit_optional = True 16 | strict_optional = True -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "fast_api_test" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["Gleb Garanin "] 6 | 7 | [tool.poetry.dependencies] 8 | python = "^3.8" 9 | fastapi = "^0.73.0" 10 | pydantic = "^1.8.2" 11 | email-validator = "^1.1.3" 12 | Jinja2 = "^3.0.1" 13 | PyYAML = "^5.4.1" 14 | python-multipart = "^0.0.5" 15 | graphene = "^3.0" 16 | asyncpg = "^0.25.0" 17 | environs = "^9.3.2" 18 | uvloop = "^0.16.0" 19 | SQLAlchemy = "^1.4.31" 20 | aioredis = "^2.0.0" 21 | passlib = "^1.7.4" 22 | fastapi_jinja = { git = "https://github.com/AGeekInside/fastapi-jinja.git", branch = "main" } 23 | sqlalchemy2-stubs = "^0.0.2a19" 24 | Authlib = "^0.15.4" 25 | itsdangerous = "^2.0.1" 26 | argon2-cffi = "^21.3.0" 27 | PyJWT = "^2.4.0" 28 | aio-pika = "^6.8.1" 29 | structlog = "^21.5.0" 30 | structlog-sentry = "^1.4.0" 31 | orjson = "^3.6.6" 32 | colorlog = "^6.6.0" 33 | uvicorn = "^0.17.0post1" 34 | omegaconf = "^2.1.1" 35 | cattrs = "^1.10.0" 36 | 37 | [tool.poetry.dev-dependencies] 38 | alembic = "^1.6.5" 39 | pytest = "^6.2.4" 40 | pytest-asyncio = "^0.17.2" 41 | pytest-cov = "^3.0.0" 42 | black = { version = "^21.12b0", python = ">=3.6" } 43 | mypy = { extras = ["plugin"], version = "^0.931" } 44 | timeout-decorator = "^0.5.0" 45 | flake8 = "^4.0.1" 46 | asynctest = "^0.13.0" 47 | async-timeout = "^4.0.2" 48 | pytest-aiohttp = "^1.0.3" 49 | asgi-lifespan = "^1.0.1" 50 | gunicorn = "^20.1.0" 51 | httpx = "0.23.0" 52 | pytest-mock = "^3.6.1" 53 | nest-asyncio = "^1.5.1" 54 | types-orjson = "^3.6.2" 55 | 56 | [build-system] 57 | requires = ["poetry-core>=1.0.0"] 58 | build-backend = "poetry.core.masonry.api" 59 | 60 | [[tool.poetry.source]] 61 | name = "pypi-public" 62 | url = "https://pypi.org/simple/" 63 | default = true -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/fastapi-project/21a4ad9835936d0dcf9de4d63ae1bed31604a6c8/src/__init__.py -------------------------------------------------------------------------------- /src/__main__.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | from typing import Union 3 | 4 | import cattrs 5 | from omegaconf import OmegaConf 6 | 7 | from src.config.config import Config, BASE_DIR 8 | from src.utils.application_builder.api_installation import Director, DevelopmentApplicationBuilder 9 | from src.utils.gunicorn_app import StandaloneApplication 10 | from src.utils.logging import LoggingConfig, configure_logging 11 | 12 | 13 | def run_application() -> None: 14 | config = _parse_config(BASE_DIR / "src" / "config" / "config.yaml") 15 | stdlib_logconfig_dict = configure_logging(LoggingConfig()) 16 | director = Director(DevelopmentApplicationBuilder(config=config)) 17 | app = director.build_app() 18 | options = { 19 | "bind": "%s:%s" % (config.server.host, config.server.port), 20 | "worker_class": "uvicorn.workers.UvicornWorker", 21 | "reload": True, 22 | "disable_existing_loggers": False, 23 | "preload_app": True, 24 | "logconfig_dict": stdlib_logconfig_dict 25 | } 26 | gunicorn_app = StandaloneApplication(app, options) 27 | gunicorn_app.run() 28 | 29 | 30 | def _parse_config(path_to_config: Union[str, pathlib.Path]) -> Config: 31 | dictionary_config = OmegaConf.to_container(OmegaConf.merge( 32 | OmegaConf.load(path_to_config), 33 | OmegaConf.structured(Config) 34 | ), resolve=True) 35 | return cattrs.structure(dictionary_config, Config) 36 | 37 | 38 | if __name__ == "__main__": 39 | run_application() 40 | -------------------------------------------------------------------------------- /src/api/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from .v1 import not_for_production 4 | from .v1.endpoints import oauth, users, products, basic, healthcheck 5 | 6 | 7 | def setup_routers() -> APIRouter: 8 | fundamental_api_router = basic.fundamental_api_router 9 | fundamental_api_router.include_router(users.api_router) 10 | fundamental_api_router.include_router(oauth.api_router) 11 | fundamental_api_router.include_router(products.api_router) 12 | fundamental_api_router.include_router(not_for_production.api_router) 13 | fundamental_api_router.include_router(healthcheck.api_router) 14 | return fundamental_api_router 15 | 16 | 17 | __all__ = ("setup_routers",) 18 | -------------------------------------------------------------------------------- /src/api/v1/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021. Lorem ipsum dolor sit amet, consectetur adipiscing elit. 2 | # Morbi non lorem porttitor neque feugiat blandit. Ut vitae ipsum eget quam lacinia accumsan. 3 | # Etiam sed turpis ac ipsum condimentum fringilla. Maecenas magna. 4 | # Proin dapibus sapien vel ante. Aliquam erat volutpat. Pellentesque sagittis ligula eget metus. 5 | # Vestibulum commodo. Ut rhoncus gravida arcu. 6 | 7 | from __future__ import annotations 8 | -------------------------------------------------------------------------------- /src/api/v1/dependencies/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021. Lorem ipsum dolor sit amet, consectetur adipiscing elit. 2 | # Morbi non lorem porttitor neque feugiat blandit. Ut vitae ipsum eget quam lacinia accumsan. 3 | # Etiam sed turpis ac ipsum condimentum fringilla. Maecenas magna. 4 | # Proin dapibus sapien vel ante. Aliquam erat volutpat. Pellentesque sagittis ligula eget metus. 5 | # Vestibulum commodo. Ut rhoncus gravida arcu. 6 | 7 | from __future__ import annotations 8 | -------------------------------------------------------------------------------- /src/api/v1/dependencies/database.py: -------------------------------------------------------------------------------- 1 | class UserRepositoryDependencyMarker: # pragma: no cover 2 | pass 3 | 4 | 5 | class ProductRepositoryDependencyMarker: # pragma: no cover 6 | pass 7 | -------------------------------------------------------------------------------- /src/api/v1/dependencies/services.py: -------------------------------------------------------------------------------- 1 | class SecurityGuardServiceDependencyMarker: 2 | pass 3 | 4 | 5 | class ServiceAuthorizationDependencyMarker: 6 | pass 7 | 8 | 9 | class OAuthServiceDependencyMarker: 10 | pass 11 | 12 | 13 | class RPCDependencyMarker: 14 | pass 15 | -------------------------------------------------------------------------------- /src/api/v1/dto.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import Optional, List 3 | 4 | from pydantic import BaseModel, Field, EmailStr 5 | 6 | from src.services.database.models import SizeEnum 7 | 8 | 9 | class UserDTO(BaseModel): 10 | first_name: str = Field(..., example="Gleb", title="Имя") 11 | last_name: str = Field(..., example="Garanin", title="Фамилия") 12 | phone_number: Optional[str] = Field( 13 | None, 14 | regex=r"^[+]?[(]?[0-9]{3}[)]?[-\s.]?[0-9]{3}[-\s.]?[0-9]{4,6}$", 15 | min_length=10, 16 | max_length=15, 17 | title="Номер мобильного телефона", 18 | example="+7900232132", 19 | ) 20 | email: EmailStr = Field( 21 | ..., title="Адрес электронной почты", example="glebgar567@gmail.com" 22 | ) 23 | balance: float 24 | password: str = Field(..., example="qwerty12345") 25 | id: Optional[int] = None 26 | username: str = Field(..., example="GLEF1X") 27 | 28 | class Config: 29 | orm_mode = True 30 | 31 | 32 | class ProductDTO(BaseModel): 33 | id: Optional[int] = None 34 | name: str 35 | unit_price: float 36 | size: SizeEnum 37 | description: str 38 | created_at: Optional[datetime.datetime] = None 39 | 40 | class Config: 41 | orm_mode = True 42 | schema_extra = { 43 | "name": "Apple MacBook 15", 44 | "unit_price": 7000, 45 | "description": "Light and fast laptop", 46 | } 47 | keep_untouched = () 48 | use_enum_values = True 49 | 50 | def patch_enum_values(self) -> None: 51 | self.Config.use_enum_values = False 52 | 53 | 54 | class DefaultResponse(BaseModel): 55 | error: str 56 | success: bool = False 57 | 58 | 59 | class ObjectCountDTO(BaseModel): 60 | count: int = Field(..., example=44) 61 | 62 | 63 | class SimpleResponse(BaseModel): 64 | message: str = Field(...) 65 | 66 | 67 | class TestResponse(BaseModel): 68 | success: bool = True 69 | user_agent: Optional[str] = Field(None, alias="User-Agent") 70 | 71 | 72 | class AccessToken(BaseModel): 73 | access_token: str 74 | token_type: str 75 | 76 | 77 | class TokenPayload(BaseModel): 78 | username: str 79 | scopes: List[str] = [] 80 | -------------------------------------------------------------------------------- /src/api/v1/endpoints/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/api/v1/endpoints/basic.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends 2 | 3 | from src.api.v1.dependencies.database import UserRepositoryDependencyMarker 4 | from src.services.database.repositories.user_repository import UserRepository 5 | from src.api.v1.dto import TestResponse 6 | 7 | fundamental_api_router = APIRouter(prefix="/api/v1") 8 | 9 | 10 | @fundamental_api_router.post("/test", tags=["Test"], response_model=TestResponse) 11 | async def test(a: UserRepository = Depends(UserRepositoryDependencyMarker)): 12 | return {"success": True} 13 | -------------------------------------------------------------------------------- /src/api/v1/endpoints/healthcheck.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from fastapi import APIRouter 4 | from fastapi.responses import ORJSONResponse 5 | 6 | api_router = APIRouter() 7 | 8 | 9 | @api_router.get("/healthcheck", response_class=ORJSONResponse, tags=["healthcheck"]) 10 | async def healthcheck(): 11 | return {"health": True} 12 | -------------------------------------------------------------------------------- /src/api/v1/endpoints/oauth.py: -------------------------------------------------------------------------------- 1 | from authlib.integrations.base_client import OAuthError 2 | from fastapi import Depends, HTTPException, APIRouter 3 | from fastapi.security import OAuth2PasswordRequestForm 4 | from starlette import status 5 | from starlette.requests import Request 6 | from starlette.responses import RedirectResponse, HTMLResponse 7 | 8 | from src.api.v1.dependencies.services import ServiceAuthorizationDependencyMarker, \ 9 | OAuthServiceDependencyMarker 10 | from src.services.security.jwt_service import JWTAuthenticationService 11 | from src.services.security.oauth import OAuthSecurityService 12 | from src.utils.exceptions import UserIsUnauthorized 13 | 14 | api_router = APIRouter() 15 | 16 | 17 | @api_router.post("/oauth", tags=["Oauth & Oauth2"], name="oauth:login") 18 | async def login( 19 | form_data: OAuth2PasswordRequestForm = Depends(), 20 | authentication_service: JWTAuthenticationService = Depends(ServiceAuthorizationDependencyMarker) 21 | # DIP on demand 22 | ): 23 | try: 24 | access_token = await authentication_service.authenticate_user(form_data) 25 | return {"access_token": access_token, "token_type": "bearer"} 26 | except UserIsUnauthorized as ex: 27 | raise HTTPException( 28 | status_code=status.HTTP_401_UNAUTHORIZED, 29 | detail=ex.hint, 30 | headers={"WWW-Authenticate": "Bearer"}, 31 | ) from ex 32 | 33 | 34 | @api_router.get("/login/google") 35 | async def login_via_google(request: Request, 36 | oauth_service: OAuthSecurityService = Depends(OAuthServiceDependencyMarker)): 37 | redirect_uri = api_router.url_path_for("oauth:google") 38 | return await oauth_service.google.authorize_redirect(request, redirect_uri) 39 | 40 | 41 | @api_router.get('/auth/google', name="oauth:google") 42 | async def auth(request: Request, oauth_service: OAuthSecurityService = Depends(OAuthServiceDependencyMarker)): 43 | try: 44 | token = await oauth_service.google.authorize_access_token(request) 45 | except OAuthError as error: 46 | return HTMLResponse(f'

{error.error}

') 47 | user = await oauth_service.google.parse_id_token(request, token) 48 | request.session['user'] = dict(user) 49 | return RedirectResponse(url='/') 50 | 51 | 52 | @api_router.get('/logout') 53 | async def logout(request: Request): 54 | request.session.pop('user', None) 55 | return RedirectResponse(url='/') 56 | -------------------------------------------------------------------------------- /src/api/v1/endpoints/products.py: -------------------------------------------------------------------------------- 1 | from fastapi import Header, Depends, APIRouter 2 | from fastapi.responses import Response 3 | 4 | from src.api.v1.dependencies.database import ProductRepositoryDependencyMarker 5 | from src.api.v1.dependencies.services import SecurityGuardServiceDependencyMarker 6 | from src.services.database.repositories.product_repository import ProductRepository 7 | from src.api.v1.dto import ProductDTO, DefaultResponse 8 | from src.utils.endpoints_specs import ProductBodySpec 9 | 10 | api_router = APIRouter(dependencies=[Depends(SecurityGuardServiceDependencyMarker)]) 11 | 12 | 13 | # noinspection PyUnusedLocal 14 | @api_router.put( 15 | "/products/create", 16 | tags=["Product"], 17 | responses={400: {"model": DefaultResponse}}, 18 | status_code=201, 19 | name="products:create_product" 20 | ) 21 | async def create_product( 22 | product: ProductDTO = ProductBodySpec.item, 23 | user_agent: str = Header(..., title="User-Agent"), 24 | product_crud: ProductRepository = Depends(ProductRepositoryDependencyMarker), 25 | ): 26 | """ 27 | Create an item with all the information: 28 | 29 | - **name**: each item must have a name 30 | - **description**: a long description 31 | - **unit_price**: required 32 | - **size**: size of item 33 | """ 34 | await product_crud.add_product(**product.dict(exclude_unset=True, exclude_none=True)) 35 | return Response(status_code=201, headers={"User-Agent": user_agent}) 36 | -------------------------------------------------------------------------------- /src/api/v1/endpoints/users.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from aio_pika.patterns import RPC 4 | from fastapi import Path, HTTPException, Depends, APIRouter 5 | from pydantic import ValidationError 6 | from sqlalchemy.exc import IntegrityError, DatabaseError 7 | from starlette.background import BackgroundTasks 8 | 9 | from src.api.v1.dependencies.database import UserRepositoryDependencyMarker 10 | from src.api.v1.dependencies.services import SecurityGuardServiceDependencyMarker, RPCDependencyMarker 11 | from src.api.v1.dto import ObjectCountDTO, SimpleResponse, UserDTO, DefaultResponse 12 | from src.resources import api_string_templates 13 | from src.services.database.repositories.user_repository import UserRepository 14 | from src.utils.endpoints_specs import UserBodySpec 15 | from src.utils.responses import NotFoundJsonResponse, BadRequestJsonResponse 16 | 17 | api_router = APIRouter(dependencies=[Depends(SecurityGuardServiceDependencyMarker)]) 18 | 19 | 20 | # noinspection PyUnusedLocal 21 | @api_router.get("/users/{user_id}/info", response_model=UserDTO, tags=["Users"], 22 | name="users:get_user_info") 23 | async def get_user_info( 24 | user_id: int, 25 | user_repository: UserRepository = Depends(UserRepositoryDependencyMarker) 26 | ): 27 | user = await user_repository.get_user_by_id(user_id) 28 | try: 29 | return UserDTO.from_orm(user) 30 | except ValidationError: 31 | return NotFoundJsonResponse(content=api_string_templates.USER_DOES_NOT_EXIST_ERROR) 32 | 33 | 34 | # noinspection PyUnusedLocal 35 | @api_router.get( 36 | "/users/all", 37 | response_model=List[UserDTO], 38 | responses={400: {"model": DefaultResponse}}, 39 | tags=["Users"], 40 | name="users:get_all_users" 41 | ) 42 | async def get_all_users(user_repository: UserRepository = Depends(UserRepositoryDependencyMarker)): 43 | return await user_repository.get_all_users() 44 | 45 | 46 | @api_router.put( 47 | "/users/create", responses={400: {"model": DefaultResponse}}, tags=["Users"], 48 | name="users:create_user" 49 | ) 50 | async def create_user( 51 | background_tasks: BackgroundTasks, 52 | user: UserDTO = UserBodySpec.item, 53 | user_repository: UserRepository = Depends(UserRepositoryDependencyMarker), 54 | rpc: RPC = Depends(RPCDependencyMarker), 55 | ): 56 | """*Create a new user in database""" 57 | payload = user.dict(exclude_unset=True) 58 | try: 59 | await user_repository.add_user(**payload) 60 | except IntegrityError: 61 | return BadRequestJsonResponse(content=api_string_templates.USERNAME_TAKEN) 62 | 63 | background_tasks.add_task(rpc.call, method_name="send_email") 64 | 65 | return {"success": True} 66 | 67 | 68 | # noinspection PyUnusedLocal 69 | @api_router.post( 70 | "/users/count", 71 | response_description="Return an integer or null", 72 | response_model=ObjectCountDTO, 73 | tags=["Users"], 74 | summary="Return count of users in database", 75 | name="users:get_users_count" 76 | ) 77 | async def get_users_count( 78 | user_repository: UserRepository = Depends(UserRepositoryDependencyMarker), 79 | ): 80 | return {"count": await user_repository.get_users_count()} 81 | 82 | 83 | # noinspection PyUnusedLocal 84 | @api_router.delete( 85 | "/users/{user_id}/delete", 86 | response_description="return nothing", 87 | tags=["Users"], 88 | summary="Delete user from database", 89 | response_model=SimpleResponse, 90 | name="users:delete_user" 91 | ) 92 | async def delete_user( 93 | user_id: int = Path(...), 94 | user_repository: UserRepository = Depends(UserRepositoryDependencyMarker), 95 | ): 96 | try: 97 | await user_repository.delete_user(user_id=user_id) 98 | except DatabaseError: 99 | raise HTTPException( 100 | status_code=400, detail=f"There isn't entry with id={user_id}" 101 | ) 102 | return {"message": f"UserDTO with id {user_id} was successfully deleted from database"} 103 | -------------------------------------------------------------------------------- /src/api/v1/errors/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021. Lorem ipsum dolor sit amet, consectetur adipiscing elit. 2 | # Morbi non lorem porttitor neque feugiat blandit. Ut vitae ipsum eget quam lacinia accumsan. 3 | # Etiam sed turpis ac ipsum condimentum fringilla. Maecenas magna. 4 | # Proin dapibus sapien vel ante. Aliquam erat volutpat. Pellentesque sagittis ligula eget metus. 5 | # Vestibulum commodo. Ut rhoncus gravida arcu. 6 | 7 | from __future__ import annotations 8 | -------------------------------------------------------------------------------- /src/api/v1/errors/http_error.py: -------------------------------------------------------------------------------- 1 | from fastapi import HTTPException 2 | from starlette.requests import Request 3 | from starlette.responses import JSONResponse 4 | 5 | 6 | async def http_error_handler(_: Request, exc: HTTPException) -> JSONResponse: 7 | return JSONResponse({"errors": [exc.detail]}, status_code=exc.status_code) 8 | -------------------------------------------------------------------------------- /src/api/v1/errors/validation_error.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from fastapi.exceptions import RequestValidationError 4 | from fastapi.openapi.constants import REF_PREFIX 5 | from fastapi.openapi.utils import validation_error_response_definition 6 | from pydantic import ValidationError 7 | from starlette.requests import Request 8 | from starlette.responses import JSONResponse 9 | from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY 10 | 11 | 12 | async def http422_error_handler( 13 | _: Request, 14 | exc: Union[RequestValidationError, ValidationError], 15 | ) -> JSONResponse: 16 | return JSONResponse( 17 | {"errors": exc.errors()}, 18 | status_code=HTTP_422_UNPROCESSABLE_ENTITY, 19 | ) 20 | 21 | 22 | validation_error_response_definition["properties"] = { 23 | "errors": { 24 | "title": "Errors", 25 | "type": "array", 26 | "items": {"$ref": "{0}ValidationError".format(REF_PREFIX)}, 27 | }, 28 | } 29 | -------------------------------------------------------------------------------- /src/api/v1/not_for_production.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Union 2 | 3 | from fastapi import APIRouter, Depends, Query, Path 4 | from starlette import status 5 | from starlette.responses import JSONResponse 6 | 7 | from src.api.v1.dto import ProductDTO 8 | from src.api.v1.dependencies.database import ProductRepositoryDependencyMarker 9 | from src.api.v1.dependencies.services import SecurityGuardServiceDependencyMarker 10 | from src.services.database.models.product import Product as _DB_Product 11 | from src.services.database.repositories.product_repository import ProductRepository 12 | from src.utils.responses import get_pydantic_model_or_return_raw_response 13 | 14 | api_router = APIRouter(dependencies=[Depends(SecurityGuardServiceDependencyMarker)]) 15 | 16 | 17 | @api_router.get( 18 | "/products/get/{product_id}", 19 | responses={200: {"model": ProductDTO}} 20 | ) 21 | async def get_product_by_id( 22 | product_id: int = Path(...), 23 | product_repository: ProductRepository = Depends(ProductRepositoryDependencyMarker), 24 | ) -> Union[JSONResponse, ProductDTO]: 25 | product: _DB_Product = await product_repository.get_product_by_id(product_id) 26 | return get_pydantic_model_or_return_raw_response(ProductDTO, product) 27 | 28 | 29 | @api_router.get("/test_api/{user_id}/items/{item_id}", status_code=status.HTTP_200_OK, 30 | include_in_schema=False) 31 | async def read_user_item( 32 | user_id: int, 33 | item_id: str, 34 | short: bool = False, 35 | q: Optional[str] = Query(None, max_length=50, deprecated=True), 36 | ): 37 | item = {"item_id": item_id, "owner_id": user_id} 38 | if q: 39 | item.update({"q": q}) 40 | if not short: 41 | item.update( 42 | {"description": "This is an amazing item that has a long description"} 43 | ) 44 | return item 45 | -------------------------------------------------------------------------------- /src/config/__init__.py: -------------------------------------------------------------------------------- 1 | from .config import Config -------------------------------------------------------------------------------- /src/config/config.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import secrets 3 | from typing import Dict, Any, List, Union 4 | 5 | from attr import Factory 6 | from attrs import define, field 7 | from omegaconf import MISSING 8 | from pydantic import PostgresDsn 9 | 10 | BASE_DIR = pathlib.Path(__file__).resolve().parent.parent.parent 11 | 12 | 13 | def _make_fastapi_instance_kwargs(settings: "ServerSettings") -> Dict[str, Any]: 14 | if ( 15 | settings.app_title == MISSING or 16 | settings.project_version == MISSING or 17 | settings.docs_url == MISSING or 18 | settings.redoc_url == MISSING or 19 | settings): 20 | return {} 21 | 22 | return { 23 | "debug": True, 24 | "title": settings.app_title, 25 | "version": settings.project_version, 26 | "docs_url": settings.docs_url, 27 | "redoc_url": settings.redoc_url, 28 | "openapi_url": settings.openapi_root 29 | if not settings.is_in_prod 30 | else "/openapi.json", 31 | } 32 | 33 | 34 | @define 35 | class DatabaseSettings: 36 | user: str = MISSING 37 | password: str = MISSING 38 | host: str = MISSING 39 | db_name: str = MISSING 40 | 41 | connection_uri: str = field(default="") 42 | 43 | def __attrs_post_init__(self) -> None: 44 | sync_connection_url = PostgresDsn.build( 45 | scheme="postgresql", 46 | user=self.user, 47 | password=self.password, 48 | host=self.host, 49 | path=f"/{self.db_name or ''}", 50 | ) 51 | # Get asyncpg+postgresql link to connect 52 | self.connection_uri = sync_connection_url.replace("postgresql", "postgresql+asyncpg") 53 | 54 | 55 | 56 | 57 | @define 58 | class SecuritySettings: 59 | jwt_secret_key: str = secrets.token_urlsafe(32) 60 | # 60 minutes * 24 hours * 8 days = 8 days 61 | jwt_access_token_expire_in_minutes: int = 60 * 24 * 8 62 | 63 | 64 | @define 65 | class ServerSettings: 66 | app_title: str = MISSING 67 | project_version: str = MISSING 68 | docs_url: str = MISSING 69 | redoc_url: str = MISSING 70 | openapi_root: str = MISSING 71 | is_in_prod: bool = MISSING 72 | templates_dir: str = (BASE_DIR / "src" / "templates") 73 | api_path_prefix: str = "/api/v1" 74 | 75 | host: str = MISSING 76 | port: int = MISSING 77 | 78 | fastapi_instance_kwargs: Dict[str, Any] = Factory(_make_fastapi_instance_kwargs, takes_self=True) 79 | security: SecuritySettings = SecuritySettings() 80 | 81 | allowed_headers: List[str] = [ 82 | "Content-Type", 83 | "Authorization", 84 | "accept", 85 | "Accept-Encoding", 86 | "Content-Length", 87 | "Origin", 88 | ] 89 | 90 | backend_cors_origins: List[str] = field( 91 | default=["http://localhost", "http://localhost:4200", "http://localhost:3000", ] 92 | ) 93 | 94 | 95 | @define 96 | class RabbitMQSettings: 97 | uri: str = MISSING 98 | 99 | 100 | @define 101 | class ExternalAPISettings: 102 | QIWI_SECRET: str = MISSING 103 | QIWI_API_TOKEN: str = MISSING 104 | PHONE_NUMBER: str = MISSING 105 | 106 | 107 | @define 108 | class Config: 109 | database: DatabaseSettings = DatabaseSettings() 110 | server: ServerSettings = ServerSettings() 111 | external_api: ExternalAPISettings = ExternalAPISettings() 112 | rabbitmq: RabbitMQSettings = RabbitMQSettings() 113 | -------------------------------------------------------------------------------- /src/config/config.yaml: -------------------------------------------------------------------------------- 1 | database: 2 | user: postgres 3 | password: postgres 4 | db_name: fastapi_db 5 | host: pg_database 6 | 7 | server: 8 | app_title: "My REST API" 9 | project_version: "1.0.0" 10 | docs_url: "/docs" 11 | redoc_url: "/redoc" 12 | openapi_root: "/openapi.json" 13 | is_in_prod: false 14 | security: 15 | jwt_secret_key: 5153fdc6bc82d5e3cda0b6637e3b6a0abb16ee38ab6ab0304e9ccb1785420e53 16 | jwt_access_token_expire_in_minutes: 21600 # 15 days 17 | 18 | host: "0.0.0.0" 19 | port: "8080" 20 | 21 | rabbitmq: 22 | uri: "amqp://glef1x:glef1x@rabbitmq" -------------------------------------------------------------------------------- /src/config/secrets.env: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/fastapi-project/21a4ad9835936d0dcf9de4d63ae1bed31604a6c8/src/config/secrets.env -------------------------------------------------------------------------------- /src/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/fastapi-project/21a4ad9835936d0dcf9de4d63ae1bed31604a6c8/src/core/__init__.py -------------------------------------------------------------------------------- /src/core/events.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Coroutine, Any 2 | 3 | from fastapi import FastAPI 4 | 5 | 6 | # noinspection PyUnusedLocal 7 | def create_on_startup_handler(app: FastAPI) -> Callable[..., Coroutine[Any, Any, None]]: 8 | async def on_startup() -> None: 9 | ... 10 | 11 | return on_startup 12 | 13 | 14 | def create_on_shutdown_handler(app: FastAPI) -> Callable[..., Coroutine[Any, Any, None]]: 15 | async def on_shutdown() -> None: 16 | await app.state.db_components.engine.dispose() 17 | 18 | return on_shutdown 19 | -------------------------------------------------------------------------------- /src/middlewares/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/fastapi-project/21a4ad9835936d0dcf9de4d63ae1bed31604a6c8/src/middlewares/__init__.py -------------------------------------------------------------------------------- /src/middlewares/process_time_middleware.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import Coroutine, Any, Callable 3 | 4 | from fastapi.openapi.models import Response 5 | from starlette.requests import Request 6 | 7 | 8 | async def add_process_time_header(request: Request, 9 | call_next: Callable[[Request], Coroutine[Any, Any, Response]]) -> Response: 10 | start_time = time.monotonic() 11 | response = await call_next(request) 12 | process_time = time.monotonic() - start_time 13 | response.headers["X-Process-Time"] = str(process_time) # type: ignore # noqa 14 | return response 15 | -------------------------------------------------------------------------------- /src/resources/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021. Lorem ipsum dolor sit amet, consectetur adipiscing elit. 2 | # Morbi non lorem porttitor neque feugiat blandit. Ut vitae ipsum eget quam lacinia accumsan. 3 | # Etiam sed turpis ac ipsum condimentum fringilla. Maecenas magna. 4 | # Proin dapibus sapien vel ante. Aliquam erat volutpat. Pellentesque sagittis ligula eget metus. 5 | # Vestibulum commodo. Ut rhoncus gravida arcu. 6 | 7 | from __future__ import annotations 8 | -------------------------------------------------------------------------------- /src/resources/api_string_templates.py: -------------------------------------------------------------------------------- 1 | USER_DOES_NOT_EXIST_ERROR = "user does not exist" 2 | 3 | INCORRECT_LOGIN_INPUT = "incorrect username or password" 4 | USERNAME_TAKEN = "user with this username already exists" 5 | EMAIL_TAKEN = "user with this email already exists" 6 | 7 | WRONG_TOKEN_PREFIX = "unsupported authorization type" # noqa: S105 8 | MALFORMED_PAYLOAD = "could not validate credentials" 9 | 10 | AUTHENTICATION_REQUIRED = "authentication required" 11 | 12 | OBJECT_NOT_FOUND = "object was not found" 13 | 14 | SCOPES_MISSING = "corresponding scopes to execute this operation are missing" 15 | TOKEN_IS_INCORRECT = "Input bearer token is incorrect" 16 | TOKEN_IS_MISSING = "Bearer token is missing" 17 | -------------------------------------------------------------------------------- /src/services/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/services/amqp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/fastapi-project/21a4ad9835936d0dcf9de4d63ae1bed31604a6c8/src/services/amqp/__init__.py -------------------------------------------------------------------------------- /src/services/amqp/mailing.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | 4 | async def send_email(): 5 | await asyncio.sleep(5) # TODO replace with real implementation 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/services/amqp/rpc.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Any, Optional, Callable, Dict, Hashable 3 | 4 | import orjson 5 | from aio_pika import connect_robust, Connection, Channel, DeliveryMode 6 | from aio_pika.patterns import RPC 7 | from aio_pika.pool import Pool 8 | from attr import define, field 9 | 10 | 11 | class JsonRPC(RPC): 12 | SERIALIZER = orjson 13 | CONTENT_TYPE = "application/json" 14 | 15 | def serialize(self, data: Any) -> bytes: 16 | return self.SERIALIZER.dumps(data, default=repr) 17 | 18 | def serialize_exception(self, exception: Exception) -> bytes: 19 | return self.serialize( 20 | { 21 | "error": { 22 | "type": exception.__class__.__name__, 23 | "message": repr(exception), 24 | "args": exception.args, 25 | } 26 | } 27 | ) 28 | 29 | 30 | 31 | @define 32 | class Consumer: 33 | callback: Callable[..., Any] 34 | name: str 35 | kwargs: Dict[str, Any] = field(factory=dict) 36 | 37 | 38 | class RabbitMQService: 39 | 40 | def __init__(self, uri: str, *consumers: Consumer, **connect_kw: Any): 41 | self._uri = uri 42 | self._connect_kw = connect_kw 43 | self._connection_pool: Pool[Connection] = Pool(self._get_connection, max_size=15) 44 | self._channel_pool: Pool[Channel] = Pool(self._get_channel, max_size=10) 45 | self._rpc: Optional[RPC] = None 46 | self._consumers = consumers 47 | 48 | async def _get_channel(self) -> Channel: 49 | async with self._connection_pool.acquire() as connection: 50 | return await connection.channel() 51 | 52 | async def _get_connection(self) -> Connection: 53 | return await connect_robust(self._uri, **self._connect_kw) 54 | 55 | async def __aenter__(self) -> RPC: 56 | async with self._connection_pool.acquire(): 57 | async with self._channel_pool.acquire() as channel: 58 | rpc = JsonRPC(channel) 59 | await rpc.initialize() 60 | self._rpc = rpc 61 | 62 | for consumer in self._consumers: 63 | await self._rpc.register(consumer.name, consumer.callback, **consumer.kwargs) 64 | 65 | return self._rpc 66 | 67 | async def __aexit__(self, exc_type, exc_val, exc_tb): pass 68 | -------------------------------------------------------------------------------- /src/services/database/__init__.py: -------------------------------------------------------------------------------- 1 | from .exceptions import DatabaseError 2 | from .models import User, Product, Order 3 | 4 | __all__ = ("DatabaseError", "User", "Product", "Order") 5 | -------------------------------------------------------------------------------- /src/services/database/exceptions.py: -------------------------------------------------------------------------------- 1 | class DatabaseError(Exception): 2 | def __init__(self, orig: Exception): 3 | self.orig = orig 4 | 5 | def __str__(self) -> str: 6 | return f". Original exception traceback:" + self.orig.__str__() 7 | -------------------------------------------------------------------------------- /src/services/database/migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration with an async dbapi. -------------------------------------------------------------------------------- /src/services/database/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021. Lorem ipsum dolor sit amet, consectetur adipiscing elit. 2 | # Morbi non lorem porttitor neque feugiat blandit. Ut vitae ipsum eget quam lacinia accumsan. 3 | # Etiam sed turpis ac ipsum condimentum fringilla. Maecenas magna. 4 | # Proin dapibus sapien vel ante. Aliquam erat volutpat. Pellentesque sagittis ligula eget metus. 5 | # Vestibulum commodo. Ut rhoncus gravida arcu. 6 | 7 | from __future__ import annotations 8 | -------------------------------------------------------------------------------- /src/services/database/migrations/env.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from logging.config import fileConfig 3 | from typing import no_type_check 4 | 5 | import nest_asyncio 6 | from alembic import context 7 | from sqlalchemy import engine_from_config, pool 8 | from sqlalchemy.ext.asyncio import AsyncEngine 9 | 10 | from src.__main__ import _parse_config 11 | from src.config.config import BASE_DIR 12 | from src.services.database.models.base import Base 13 | 14 | target_metadata = Base.metadata 15 | 16 | config = context.config # type: ignore 17 | fileConfig(config.config_file_name) 18 | application_settings = _parse_config(BASE_DIR / "src" / "config" / "config.yaml") 19 | config.set_main_option("sqlalchemy.url", application_settings.database.connection_uri) 20 | 21 | 22 | def run_migrations_offline(): 23 | """Run migrations in 'offline' mode. 24 | 25 | This configures the context with just a URL 26 | and not an Engine, though an Engine is acceptable 27 | here as well. By skipping the Engine creation 28 | we don't even need a DBAPI to be available. 29 | 30 | Calls to context.execute() here emit the given string to the 31 | script output. 32 | 33 | """ 34 | url = config.get_main_option("sqlalchemy.url") 35 | context.configure( 36 | url=url, 37 | target_metadata=target_metadata, 38 | literal_binds=True, 39 | dialect_opts={"paramstyle": "named"}, 40 | compare_server_default=True, 41 | compare_type=True, 42 | ) 43 | 44 | with context.begin_transaction(): 45 | context.run_migrations() 46 | 47 | 48 | @no_type_check 49 | def do_run_migrations(connection): 50 | context.configure( 51 | connection=connection, 52 | target_metadata=target_metadata, 53 | compare_server_default=True, 54 | compare_type=True, 55 | include_schemas=True, 56 | ) 57 | 58 | with context.begin_transaction(): 59 | context.run_migrations() 60 | 61 | 62 | async def run_migrations_online(): 63 | """Run migrations in 'online' mode. 64 | 65 | In this scenario we need to create an Engine 66 | and associate a connection with the context. 67 | 68 | """ 69 | connectable = AsyncEngine( 70 | engine_from_config( 71 | config.get_section(config.config_ini_section), 72 | prefix="sqlalchemy.", # noqa 73 | poolclass=pool.NullPool, 74 | future=True, 75 | ) 76 | ) 77 | 78 | async with connectable.connect() as connection: 79 | await connection.run_sync(do_run_migrations) 80 | 81 | 82 | if context.is_offline_mode(): 83 | run_migrations_offline() 84 | else: 85 | nest_asyncio.apply() 86 | asyncio.get_event_loop().run_until_complete(run_migrations_online()) 87 | -------------------------------------------------------------------------------- /src/services/database/migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /src/services/database/migrations/versions/799986945827_first_migration.py: -------------------------------------------------------------------------------- 1 | """first migration 2 | 3 | Revision ID: 799986945827 4 | Revises: 5 | Create Date: 2021-08-14 15:18:48.150866 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | revision = '799986945827' 12 | down_revision = None 13 | branch_labels = None 14 | depends_on = None 15 | 16 | 17 | def upgrade(): 18 | # ### commands auto generated by Alembic - please adjust! ### 19 | op.create_table('products', 20 | sa.Column('id', sa.Integer(), sa.Identity(always=True, cache=5), nullable=False), 21 | sa.Column('name', sa.VARCHAR(length=255), nullable=True), 22 | sa.Column('unit_price', sa.Numeric(precision=8), server_default='1', nullable=True), 23 | sa.Column('size', sa.Enum('SMALL', 'MEDIUM', 'LARGE', 'VERY_LARGE', name='sizeenum'), 24 | nullable=True), 25 | sa.Column('description', sa.Text(), nullable=True), 26 | sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), 27 | sa.PrimaryKeyConstraint('id') 28 | ) 29 | op.create_index(op.f('ix_products_name'), 'products', ['name'], unique=True) 30 | op.create_table('users', 31 | sa.Column('id', sa.BigInteger(), sa.Identity(always=True, cache=5), nullable=False), 32 | sa.Column('first_name', sa.VARCHAR(length=100), nullable=True), 33 | sa.Column('last_name', sa.VARCHAR(length=100), nullable=True), 34 | sa.Column('phone_number', sa.Text(), nullable=True), 35 | sa.Column('email', sa.VARCHAR(length=70), nullable=True), 36 | sa.Column('password_hash', sa.VARCHAR(length=100), nullable=True), 37 | sa.Column('balance', sa.DECIMAL(), server_default='0', nullable=True), 38 | sa.Column('username', sa.VARCHAR(length=70), nullable=False), 39 | sa.PrimaryKeyConstraint('id'), 40 | sa.UniqueConstraint('email') 41 | ) 42 | op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True) 43 | op.create_table('orders', 44 | sa.Column('order_id', sa.Integer(), sa.Identity(always=True, cache=5), nullable=False), 45 | sa.Column('product_id', sa.SmallInteger(), nullable=True), 46 | sa.Column('quantity', sa.SmallInteger(), server_default='1', nullable=True), 47 | sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), 48 | sa.ForeignKeyConstraint(['product_id'], ['products.id'], onupdate='CASCADE', 49 | ondelete='CASCADE'), 50 | sa.PrimaryKeyConstraint('order_id') 51 | ) 52 | # ### end Alembic commands ### 53 | 54 | 55 | def downgrade(): 56 | # ### commands auto generated by Alembic - please adjust! ### 57 | op.drop_table('orders') 58 | op.drop_index(op.f('ix_users_username'), table_name='users') 59 | op.drop_table('users') 60 | op.drop_index(op.f('ix_products_name'), table_name='products') 61 | op.drop_table('products') 62 | enum = sa.Enum('SMALL', 'MEDIUM', 'LARGE', 'VERY_LARGE', name='sizeenum') 63 | enum.drop(bind=op.get_bind()) 64 | # ### end Alembic commands ### 65 | -------------------------------------------------------------------------------- /src/services/database/migrations/versions/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021. Lorem ipsum dolor sit amet, consectetur adipiscing elit. 2 | # Morbi non lorem porttitor neque feugiat blandit. Ut vitae ipsum eget quam lacinia accumsan. 3 | # Etiam sed turpis ac ipsum condimentum fringilla. Maecenas magna. 4 | # Proin dapibus sapien vel ante. Aliquam erat volutpat. Pellentesque sagittis ligula eget metus. 5 | # Vestibulum commodo. Ut rhoncus gravida arcu. 6 | 7 | from __future__ import annotations 8 | -------------------------------------------------------------------------------- /src/services/database/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .order import Order 2 | from .product import SizeEnum, Product 3 | from .user import User 4 | 5 | __all__ = ("SizeEnum", "User", "Order", "Product") 6 | -------------------------------------------------------------------------------- /src/services/database/models/base.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from typing import Optional, cast, Type, Dict, Any 4 | from sqlalchemy import inspect, event 5 | from sqlalchemy.dialects.postgresql.asyncpg import ( 6 | AsyncAdapt_asyncpg_cursor, 7 | PGExecutionContext_asyncpg, 8 | ) 9 | from sqlalchemy.engine import URL 10 | from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, AsyncConnection 11 | from sqlalchemy.future import Connection 12 | from sqlalchemy.orm import sessionmaker 13 | from sqlalchemy.orm.decl_api import ( 14 | registry, 15 | DeclarativeMeta, 16 | declared_attr, 17 | has_inherited_table 18 | ) 19 | from sqlalchemy.util import ImmutableProperties 20 | 21 | logger = logging.getLogger("sqlalchemy.execution") 22 | 23 | mapper_registry = registry() 24 | ASTERISK = "*" 25 | 26 | 27 | class Base(metaclass=DeclarativeMeta): 28 | """Declarative meta for mypy""" 29 | 30 | __abstract__ = True 31 | __mapper_args__ = {"eager_defaults": True} 32 | 33 | # these are supplied by the sqlalchemy-stubs or sqlalchemy2-stubs, so may be omitted 34 | # when they are installed 35 | registry = mapper_registry 36 | metadata = mapper_registry.metadata 37 | 38 | @declared_attr 39 | def __tablename__(self) -> Optional[str]: 40 | if not has_inherited_table(cast(Type[Base], self)): 41 | return cast(Type[Base], self).__qualname__.lower() + "s" 42 | return None 43 | 44 | def _get_attributes(self) -> Dict[Any, Any]: 45 | return {k: v for k, v in self.__dict__.items() if not k.startswith("_")} 46 | 47 | def __str__(self) -> str: 48 | attributes = "|".join(str(v) for k, v in self._get_attributes().items()) 49 | return f"{self.__class__.__qualname__} {attributes}" 50 | 51 | def __repr__(self) -> str: 52 | table_attrs = cast(ImmutableProperties, inspect(self).attrs) 53 | primary_keys = " ".join( 54 | f"{key.name}={table_attrs[key.name].value}" 55 | for key in inspect(self.__class__).primary_key 56 | ) 57 | return f"{self.__class__.__qualname__}->{primary_keys}" 58 | 59 | def as_dict(self) -> Dict[Any, Any]: 60 | return self._get_attributes() 61 | 62 | 63 | # noinspection PyUnusedLocal 64 | def before_execute_handler( 65 | conn: Connection, 66 | cursor: AsyncAdapt_asyncpg_cursor, 67 | statement: str, 68 | parameters: tuple, 69 | context: PGExecutionContext_asyncpg, 70 | executemany: bool, 71 | ): 72 | conn.info.setdefault("query_start_time", []).append(time.monotonic()) 73 | 74 | 75 | # noinspection PyUnusedLocal 76 | def after_execute( 77 | conn: Connection, 78 | cursor: AsyncAdapt_asyncpg_cursor, 79 | statement: str, 80 | parameters: tuple, 81 | context: PGExecutionContext_asyncpg, 82 | executemany: bool, 83 | ): 84 | total = time.monotonic() - conn.info["query_start_time"].pop(-1) 85 | # sqlalchemy bug, executed twice `#4181` issue number 86 | logger.debug("Query Complete!") 87 | logger.debug("Total Time: %s", total) 88 | 89 | 90 | class DatabaseComponents: 91 | def __init__(self, connection_uri: str, **engine_kwargs) -> None: 92 | self.__engine_kwargs = engine_kwargs or {} 93 | self.engine = create_async_engine(url=connection_uri, **self.__engine_kwargs) 94 | self.sessionmaker = sessionmaker( # NOQA 95 | self.engine, class_=AsyncSession, expire_on_commit=False, autoflush=False 96 | ) 97 | self.setup_db_events() 98 | 99 | def setup_db_events(self) -> None: 100 | event.listen( 101 | self.engine.sync_engine, "before_cursor_execute", before_execute_handler 102 | ) 103 | event.listen(self.engine.sync_engine, "after_cursor_execute", after_execute) 104 | 105 | async def recreate(self) -> None: 106 | async with self.engine.begin() as conn: 107 | await conn.run_sync(Base.metadata.drop_all) 108 | await conn.run_sync(Base.metadata.create_all) 109 | 110 | @staticmethod 111 | async def drop_all(conn: AsyncConnection) -> None: 112 | await conn.run_sync(Base.metadata.drop_all) 113 | -------------------------------------------------------------------------------- /src/services/database/models/order.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy as sa 2 | from sqlalchemy import Identity 3 | 4 | from src.services.database.models.base import Base 5 | 6 | 7 | class Order(Base): 8 | """Таблица заказов""" 9 | 10 | order_id = sa.Column(sa.Integer, Identity(always=True, cache=5), primary_key=True) 11 | product_id = sa.Column( 12 | sa.SmallInteger, 13 | sa.ForeignKey("products.id", ondelete="CASCADE", onupdate="CASCADE"), 14 | ) 15 | quantity = sa.Column(sa.SmallInteger, server_default="1") 16 | created_at = sa.Column(sa.DateTime(), server_default=sa.func.now()) # type: ignore 17 | -------------------------------------------------------------------------------- /src/services/database/models/product.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | import sqlalchemy as sa 4 | from sqlalchemy import Identity 5 | 6 | from src.services.database.models.base import Base 7 | 8 | 9 | class SizeEnum(enum.Enum): 10 | SMALL = "S" 11 | MEDIUM = "M" 12 | LARGE = "XL" 13 | VERY_LARGE = "XXL" 14 | 15 | 16 | class Product(Base): 17 | """Таблица продуктов""" 18 | 19 | id = sa.Column(sa.Integer, Identity(always=True, cache=5), primary_key=True) 20 | name = sa.Column(sa.VARCHAR(255), unique=True, index=True) 21 | unit_price = sa.Column(sa.Numeric(precision=8), server_default="1") 22 | size = sa.Column(sa.Enum(SizeEnum)) 23 | description = sa.Column(sa.Text, default=None, nullable=True) 24 | created_at = sa.Column(sa.DateTime(), server_default=sa.func.now()) # type: ignore 25 | -------------------------------------------------------------------------------- /src/services/database/models/user.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy as sa 2 | from sqlalchemy import Identity, VARCHAR 3 | 4 | from src.services.database.models.base import Base 5 | 6 | 7 | class User(Base): 8 | id = sa.Column(sa.BigInteger, Identity(always=True, cache=5), primary_key=True) 9 | first_name = sa.Column(sa.VARCHAR(100), unique=False) 10 | last_name = sa.Column(sa.VARCHAR(100), unique=False) 11 | phone_number = sa.Column(sa.Text, unique=False) 12 | email = sa.Column(sa.VARCHAR(70), unique=True) 13 | password_hash = sa.Column(VARCHAR(100), unique=False) 14 | balance = sa.Column(sa.DECIMAL, server_default="0") 15 | username = sa.Column(sa.VARCHAR(70), nullable=False, unique=True, index=True) 16 | -------------------------------------------------------------------------------- /src/services/database/repositories/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/fastapi-project/21a4ad9835936d0dcf9de4d63ae1bed31604a6c8/src/services/database/repositories/__init__.py -------------------------------------------------------------------------------- /src/services/database/repositories/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import contextlib 4 | import typing 5 | from abc import ABC 6 | from typing import cast 7 | 8 | from sqlalchemy import lambda_stmt, select, update, exists, delete, func 9 | from sqlalchemy.dialects.postgresql import insert 10 | from sqlalchemy.ext.asyncio import AsyncSessionTransaction, AsyncSession 11 | from sqlalchemy.orm import sessionmaker, Session 12 | from sqlalchemy.sql import Executable 13 | 14 | from src.services.database.models.base import ASTERISK 15 | 16 | Model = typing.TypeVar("Model") 17 | TransactionContext = typing.AsyncContextManager[AsyncSessionTransaction] 18 | 19 | 20 | class BaseRepository(ABC, typing.Generic[Model]): 21 | """ 22 | We define a base class for the repository hierarchy, making it possible to use the base CRUD methods, 23 | so that they do not creep into inherited classes 24 | """ 25 | 26 | # You have to define this variable in child classes 27 | model: typing.ClassVar[typing.Type[Model]] 28 | 29 | def __init__(self, session_or_pool: typing.Union[sessionmaker, AsyncSession]) -> None: 30 | """ 31 | 32 | :param session_or_pool: async session from async context manager 33 | """ 34 | if isinstance(session_or_pool, sessionmaker): 35 | self._session: AsyncSession = typing.cast(AsyncSession, session_or_pool()) 36 | else: 37 | self._session = session_or_pool 38 | 39 | @contextlib.asynccontextmanager 40 | async def __transaction(self) -> typing.AsyncGenerator: 41 | """Yield an :class:`_asyncio.AsyncSessionTransaction` object.""" 42 | if not self._session.in_transaction() and self._session.is_active: 43 | async with self._session.begin() as transaction: # type: AsyncSessionTransaction 44 | yield transaction 45 | else: 46 | yield # type: ignore 47 | 48 | @staticmethod 49 | def proxy_bulk_save(session: Session, *instances) -> None: 50 | return session.bulk_save_objects(*instances) 51 | 52 | @property 53 | def _transaction(self) -> TransactionContext: 54 | """Mypy friendly :function:`BaseRepository.transaction` representation""" 55 | return self.__transaction() 56 | 57 | async def _insert(self, **values: typing.Any) -> Model: 58 | """Add model into database""" 59 | async with self._transaction: 60 | insert_stmt = ( 61 | insert(self.model) 62 | .values(**values) 63 | .returning(self.model) 64 | ) 65 | result = (await self._session.execute(insert_stmt)).mappings().first() 66 | return self._convert_to_model(typing.cast(typing.Dict[str, typing.Any], result)) 67 | 68 | async def _select_all(self, *clauses: typing.Any) -> typing.List[Model]: 69 | """ 70 | Selecting data from table and filter by kwargs data 71 | 72 | :param clauses: 73 | :return: 74 | """ 75 | query_model = self.model 76 | stmt = lambda_stmt(lambda: select(query_model)) 77 | stmt += lambda s: s.where(*clauses) 78 | async with self._transaction: 79 | result = ( 80 | (await self._session.execute(typing.cast(Executable, stmt))) 81 | .scalars() 82 | .all() 83 | ) 84 | 85 | return result 86 | 87 | async def _select_one(self, *clauses: typing.Any) -> Model: 88 | """ 89 | Return scalar value 90 | 91 | :return: 92 | """ 93 | query_model = self.model 94 | stmt = lambda_stmt(lambda: select(query_model)) 95 | stmt += lambda s: s.where(*clauses) 96 | async with self._transaction: 97 | result = ( 98 | (await self._session.execute(typing.cast(Executable, stmt))) 99 | .scalars() 100 | .first() 101 | ) 102 | 103 | return typing.cast(Model, result) 104 | 105 | async def _update(self, *clauses: typing.Any, **values: typing.Any) -> None: 106 | """ 107 | Update values in database, filter by `telegram_id` 108 | 109 | :param clauses: where conditionals 110 | :param values: key/value for update 111 | :return: 112 | """ 113 | async with self._transaction: 114 | stmt = update(self.model).where(*clauses).values(**values).returning(None) 115 | await self._session.execute(stmt) 116 | return None 117 | 118 | async def _exists(self, *clauses: typing.Any) -> typing.Optional[bool]: 119 | """Check is user exists in database""" 120 | async with self._transaction: 121 | stmt = exists(select(self.model).where(*clauses)).select() 122 | result = (await self._session.execute(stmt)).scalar() 123 | return typing.cast(typing.Optional[bool], result) 124 | 125 | async def _delete(self, *clauses: typing.Any) -> typing.List[Model]: 126 | async with self._transaction: 127 | stmt = delete(self.model).where(*clauses).returning(ASTERISK) 128 | result = (await self._session.execute(stmt)).mappings().all() 129 | return list(map(self._convert_to_model, result)) 130 | 131 | async def _count(self) -> int: 132 | async with self._transaction: 133 | count = (await self._session.execute(func.count(ASTERISK))).scalars().first() 134 | return cast(int, count) 135 | 136 | def _convert_to_model(self, kwargs) -> Model: 137 | return self.model(**kwargs) # type: ignore 138 | -------------------------------------------------------------------------------- /src/services/database/repositories/product_repository.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from datetime import datetime 3 | from decimal import Decimal 4 | 5 | from src.services.database import Product 6 | from src.services.database.models import SizeEnum 7 | from src.services.database.repositories.base import BaseRepository, Model 8 | from src.utils.database_utils import manual_cast, filter_payload 9 | 10 | 11 | class ProductRepository(BaseRepository[Product]): 12 | model = Product 13 | 14 | async def add_product(self, *, 15 | name: str, 16 | unit_price: typing.Union[float, Decimal], 17 | size: SizeEnum, 18 | product_id: typing.Optional[int] = None, 19 | description: typing.Optional[str] = None, 20 | created_at: typing.Optional[datetime] = None 21 | ) -> Model: 22 | payload = filter_payload(locals()) 23 | return manual_cast(await self._insert(**payload)) 24 | 25 | async def get_product_by_id(self, product_id: int) -> Model: 26 | return manual_cast(await self._select_one(self.model.id == product_id)) 27 | -------------------------------------------------------------------------------- /src/services/database/repositories/user_repository.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from decimal import Decimal 3 | 4 | from sqlalchemy.exc import IntegrityError 5 | from sqlalchemy.ext.asyncio import AsyncSession 6 | from sqlalchemy.orm import sessionmaker 7 | 8 | from src.services.database import DatabaseError 9 | from src.services.database.models import User 10 | from src.services.database.repositories.base import BaseRepository, Model 11 | from src.utils.database_utils import manual_cast, filter_payload 12 | from src.utils.password_hashing.protocol import PasswordHasherProto 13 | 14 | 15 | class UserRepository(BaseRepository[User]): 16 | model = User 17 | 18 | def __init__(self, session_or_pool: typing.Union[sessionmaker, AsyncSession], 19 | password_hasher: PasswordHasherProto): 20 | super().__init__(session_or_pool) 21 | self._password_hasher = password_hasher 22 | 23 | async def add_user(self, *, first_name: str, last_name: str, 24 | phone_number: str, email: str, password: str, balance: typing.Union[Decimal, float, None] = None, 25 | username: typing.Optional[str] = None) -> Model: 26 | prepared_payload = filter_payload(locals(), exclude=('password', )) 27 | prepared_payload["password_hash"] = self._password_hasher.hash(password) 28 | return manual_cast(await self._insert(**prepared_payload)) 29 | 30 | async def delete_user(self, user_id: int) -> None: 31 | try: 32 | await self._delete(self.model.id == user_id) 33 | except IntegrityError as ex: 34 | raise DatabaseError(orig=ex) 35 | 36 | async def get_user_by_username(self, username: str) -> Model: 37 | return manual_cast(await self._select_one(self.model.username == username)) 38 | 39 | async def get_user_by_id(self, user_id: int) -> Model: 40 | return manual_cast(await self._select_one(self.model.id == user_id)) 41 | 42 | async def get_all_users(self) -> typing.List[Model]: 43 | return manual_cast(await self._select_all(), typing.List[Model]) 44 | 45 | async def get_users_count(self) -> int: 46 | return await self._count() 47 | 48 | async def update_password_hash(self, new_pwd_hash: str, user_id: int) -> None: 49 | await self._update(self.model.id == user_id, password_hash=new_pwd_hash) 50 | 51 | -------------------------------------------------------------------------------- /src/services/security/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/fastapi-project/21a4ad9835936d0dcf9de4d63ae1bed31604a6c8/src/services/security/__init__.py -------------------------------------------------------------------------------- /src/services/security/jwt_service.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from typing import NewType, Any, Dict 3 | 4 | import jwt 5 | from argon2.exceptions import VerificationError 6 | from fastapi import HTTPException 7 | from fastapi.security import SecurityScopes, OAuth2PasswordBearer, OAuth2PasswordRequestForm 8 | from pydantic import ValidationError 9 | from starlette import status 10 | from starlette.requests import Request 11 | 12 | from src.api.v1.dto import TokenPayload 13 | from src.resources import api_string_templates 14 | from src.services.database.models.user import User 15 | from src.services.database.repositories.user_repository import UserRepository 16 | from src.utils.exceptions import UserIsUnauthorized 17 | from src.utils.password_hashing.protocol import PasswordHasherProto 18 | 19 | JWTToken = NewType("JWTToken", str) 20 | 21 | 22 | class JWTSecurityGuardService: 23 | 24 | def __init__(self, oauth2_scheme: OAuth2PasswordBearer, user_repository: UserRepository, 25 | password_hasher: PasswordHasherProto, secret_key: str, algorithm: str): 26 | self._oauth2_scheme = oauth2_scheme 27 | self._secret_key = secret_key 28 | self._algorithm = algorithm 29 | self._user_repository = user_repository 30 | self._password_hasher = password_hasher 31 | 32 | async def __call__(self, request: Request, security_scopes: SecurityScopes) -> User: 33 | jwt_token = await self._oauth2_scheme(request) 34 | if jwt_token is None: 35 | raise HTTPException( 36 | status_code=status.HTTP_401_UNAUTHORIZED, 37 | detail=api_string_templates.TOKEN_IS_MISSING, 38 | headers={"WWW-Authenticate": "Bearer"}, 39 | ) 40 | 41 | token_payload = self._decode_token(token=jwt_token) 42 | 43 | for scope in security_scopes.scopes: 44 | if scope not in token_payload.scopes: 45 | raise HTTPException( 46 | status_code=status.HTTP_401_UNAUTHORIZED, 47 | detail=api_string_templates.SCOPES_MISSING, 48 | headers={"WWW-Authenticate": "Bearer"}, 49 | ) 50 | 51 | return await self._retrieve_user_or_raise_exception(token_payload.username) 52 | 53 | def _decode_token(self, token: str) -> TokenPayload: 54 | try: 55 | payload = jwt.decode(token, self._secret_key, algorithm=self._algorithm) 56 | return TokenPayload(username=payload["username"], scopes=payload.get("scopes", [])) 57 | except (jwt.DecodeError, ValidationError): 58 | raise HTTPException( 59 | status_code=status.HTTP_401_UNAUTHORIZED, 60 | detail=api_string_templates.TOKEN_IS_INCORRECT, 61 | headers={"WWW-Authenticate": "Bearer"}, 62 | ) 63 | 64 | async def _retrieve_user_or_raise_exception(self, username: str) -> User: 65 | if user := await self._user_repository.get_user_by_username(username=username): # type: User 66 | return user 67 | 68 | raise HTTPException( 69 | status_code=status.HTTP_401_UNAUTHORIZED, 70 | detail=api_string_templates.USER_DOES_NOT_EXIST_ERROR, 71 | headers={"WWW-Authenticate": "Bearer"}, 72 | ) 73 | 74 | 75 | class JWTAuthenticationService: 76 | def __init__(self, user_repository: UserRepository, password_hasher: PasswordHasherProto, 77 | secret_key: str, algorithm: str, token_expires_in_minutes: float = 30) -> None: 78 | self._token_expires_in_minutes = token_expires_in_minutes 79 | self._secret_key = secret_key 80 | self._algorithm = algorithm 81 | self._user_repository = user_repository 82 | self._password_hasher = password_hasher 83 | 84 | async def authenticate_user(self, form_data: OAuth2PasswordRequestForm) -> JWTToken: 85 | if not (user := await self._user_repository.get_user_by_username(form_data.username)): 86 | raise UserIsUnauthorized(hint=api_string_templates.INCORRECT_LOGIN_INPUT) 87 | 88 | if self._password_hasher.check_needs_rehash(user.password_hash): 89 | await self._user_repository.update_password_hash( 90 | new_pwd_hash=self._password_hasher.hash(form_data.password), 91 | user_id=user.id 92 | ) 93 | 94 | try: 95 | self._password_hasher.verify(user.password_hash, form_data.password) 96 | except VerificationError: 97 | raise UserIsUnauthorized(hint=api_string_templates.INCORRECT_LOGIN_INPUT) 98 | 99 | return JWTToken(self._generate_jwt_token({ 100 | "sub": form_data.username, 101 | "scopes": form_data.scopes, 102 | })) 103 | 104 | def _generate_jwt_token(self, token_payload: Dict[str, Any]) -> str: 105 | token_payload = { 106 | "exp": datetime.utcnow() + timedelta(self._token_expires_in_minutes), 107 | **token_payload 108 | } 109 | filtered_payload = {k: v for k, v in token_payload.items() if v is not None} 110 | return jwt.encode(filtered_payload, self._secret_key, algorithm=self._algorithm) 111 | -------------------------------------------------------------------------------- /src/services/security/oauth.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Dict, Any 3 | 4 | from authlib.integrations.starlette_client import OAuth 5 | from authlib.oauth2 import OAuth2Client 6 | from starlette.config import Config 7 | 8 | 9 | @dataclass 10 | class OAuthIntegration: 11 | name: str 12 | overwrite: bool 13 | kwargs: Dict[str, Any] 14 | 15 | 16 | class OAuthSecurityService: 17 | 18 | def __init__(self, oauth_config: Config, *oauth_integrations: OAuthIntegration): 19 | self._oauth_client = OAuth(oauth_config) 20 | for integration in oauth_integrations: 21 | self._oauth_client.register( 22 | name=integration.name, 23 | overwrite=integration.overwrite, 24 | **integration.kwargs 25 | ) 26 | 27 | def __getattr__(self, key: str) -> OAuth2Client: 28 | """get registered integrations from oauth_client""" 29 | try: 30 | return object.__getattribute__(self, key) 31 | except AttributeError: 32 | return self._oauth_client.__getattr__(key) 33 | -------------------------------------------------------------------------------- /src/templates/home/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Fake Pypi 6 | 7 | 8 |

Fake Pypi

9 |
UserDTO: {{ user_name }}
10 | 15 | 16 | -------------------------------------------------------------------------------- /src/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/fastapi-project/21a4ad9835936d0dcf9de4d63ae1bed31604a6c8/src/utils/__init__.py -------------------------------------------------------------------------------- /src/utils/application_builder/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/fastapi-project/21a4ad9835936d0dcf9de4d63ae1bed31604a6c8/src/utils/application_builder/__init__.py -------------------------------------------------------------------------------- /src/utils/application_builder/api_installation.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | from typing import Any, Optional, Dict, no_type_check 3 | 4 | from argon2 import PasswordHasher 5 | from fastapi import FastAPI 6 | from fastapi.exceptions import RequestValidationError, HTTPException 7 | from fastapi.openapi.utils import get_openapi 8 | from fastapi.security import OAuth2PasswordBearer 9 | from starlette.config import Config as StarletteConfig 10 | from starlette.middleware.base import BaseHTTPMiddleware 11 | from starlette.middleware.cors import CORSMiddleware 12 | from starlette.middleware.sessions import SessionMiddleware 13 | 14 | from src.api import setup_routers 15 | from src.api.v1.dependencies.database import UserRepositoryDependencyMarker, ProductRepositoryDependencyMarker 16 | from src.api.v1.dependencies.services import SecurityGuardServiceDependencyMarker, \ 17 | ServiceAuthorizationDependencyMarker, OAuthServiceDependencyMarker, RPCDependencyMarker 18 | from src.api.v1.errors.http_error import http_error_handler 19 | from src.api.v1.errors.validation_error import http422_error_handler 20 | from src.config.config import Config, BASE_DIR 21 | from src.core.events import create_on_startup_handler, create_on_shutdown_handler 22 | from src.middlewares.process_time_middleware import add_process_time_header 23 | from src.services.amqp.mailing import send_email 24 | from src.services.amqp.rpc import RabbitMQService, Consumer 25 | from src.services.database.models.base import DatabaseComponents 26 | from src.services.database.repositories.product_repository import ProductRepository 27 | from src.services.database.repositories.user_repository import UserRepository 28 | from src.services.security.jwt_service import JWTSecurityGuardService, JWTAuthenticationService 29 | from src.services.security.oauth import OAuthSecurityService, OAuthIntegration 30 | from src.utils.application_builder.builder_base import AbstractFastAPIApplicationBuilder 31 | from src.views import setup_routes 32 | 33 | ALLOWED_METHODS = ["POST", "PUT", "DELETE", "GET"] 34 | 35 | 36 | class DevelopmentApplicationBuilder(AbstractFastAPIApplicationBuilder): 37 | """Class, that provides the installation of FastAPI application""" 38 | 39 | def __init__(self, config: Config) -> None: 40 | super(DevelopmentApplicationBuilder, self).__init__(config=config) 41 | self.app: FastAPI = FastAPI(**self._config.server.fastapi_instance_kwargs) # type: ignore 42 | self.app.config = self._config # type: ignore 43 | self._openapi_schema: Optional[Dict[str, Any]] = None 44 | 45 | def configure_openapi_schema(self) -> None: 46 | self._openapi_schema = get_openapi( 47 | title="GLEF1X API", 48 | version="0.0.1", 49 | description="This is a very custom OpenAPI schema", 50 | routes=self.app.routes, 51 | ) 52 | self._openapi_schema["info"]["x-logo"] = { 53 | "url": "https://fastapi.tiangolo.com/img/logo-margin/logo-teal.png" 54 | } 55 | self.app.openapi_schema = self._openapi_schema 56 | 57 | @no_type_check 58 | def setup_middlewares(self): 59 | self.app.add_middleware(BaseHTTPMiddleware, dispatch=add_process_time_header) 60 | self.app.add_middleware( 61 | middleware_class=CORSMiddleware, 62 | allow_origins=self._config.server.backend_cors_origins, 63 | allow_credentials=True, 64 | allow_methods=ALLOWED_METHODS, 65 | allow_headers=self._config.server.backend_cors_origins, 66 | expose_headers=["User-Agent", "Authorization"], 67 | ) 68 | self.app.add_middleware( 69 | middleware_class=SessionMiddleware, 70 | secret_key="!secret" 71 | ) # TODO replace with real token 72 | 73 | @no_type_check 74 | def configure_routes(self): 75 | setup_routes(self.app) 76 | self.app.include_router(setup_routers()) 77 | 78 | def configure_events(self) -> None: 79 | self.app.add_event_handler("startup", create_on_startup_handler(self.app)) 80 | self.app.add_event_handler("shutdown", create_on_shutdown_handler(self.app)) 81 | 82 | def configure_exception_handlers(self) -> None: 83 | self.app.add_exception_handler(RequestValidationError, http422_error_handler) 84 | self.app.add_exception_handler(HTTPException, http_error_handler) 85 | 86 | def configure_application_state(self) -> None: 87 | db_components = DatabaseComponents(self._config.database.connection_uri) 88 | # do gracefully dispose engine on shutdown application 89 | self.app.state.db_components = db_components 90 | self.app.state.config = self._config 91 | 92 | pwd_hasher = PasswordHasher() 93 | rmq_service = RabbitMQService( 94 | self._config.rabbitmq.uri, 95 | Consumer(name="send_email", callback=send_email) 96 | ) 97 | 98 | async def rmq_service_spin_up(): 99 | async with rmq_service as rpc: 100 | yield rpc 101 | 102 | self.app.dependency_overrides.update( 103 | { 104 | UserRepositoryDependencyMarker: lambda: UserRepository( 105 | db_components.sessionmaker, pwd_hasher 106 | ), 107 | ProductRepositoryDependencyMarker: lambda: ProductRepository(db_components.sessionmaker), 108 | OAuthServiceDependencyMarker: lambda: OAuthSecurityService( 109 | StarletteConfig(BASE_DIR / ".env"), 110 | OAuthIntegration( 111 | name='google', 112 | overwrite=False, 113 | kwargs=dict( 114 | server_metadata_url='https://accounts.google.com/.well-known/openid-configuration', 115 | client_kwargs={ 116 | 'scope': 'openid email profile' 117 | } 118 | ) 119 | ) 120 | ), 121 | SecurityGuardServiceDependencyMarker: lambda: JWTSecurityGuardService( 122 | oauth2_scheme=OAuth2PasswordBearer( 123 | tokenUrl="/api/v1/oauth", 124 | scopes={ 125 | "me": "Read information about the current user.", 126 | "items": "Read items." 127 | }, 128 | ), 129 | user_repository=UserRepository(db_components.sessionmaker, pwd_hasher), 130 | password_hasher=pwd_hasher, 131 | secret_key=self._config.server.security.jwt_secret_key, 132 | algorithm="HS256" 133 | ), 134 | ServiceAuthorizationDependencyMarker: lambda: JWTAuthenticationService( 135 | user_repository=UserRepository(db_components.sessionmaker, pwd_hasher), 136 | password_hasher=pwd_hasher, 137 | secret_key=self._config.server.security.jwt_secret_key, 138 | algorithm="HS256", 139 | token_expires_in_minutes=self._config.server.security.jwt_access_token_expire_in_minutes 140 | ), 141 | RPCDependencyMarker: rmq_service_spin_up 142 | } 143 | ) 144 | 145 | 146 | class Director: 147 | def __init__(self, builder: AbstractFastAPIApplicationBuilder) -> None: 148 | if not isinstance(builder, AbstractFastAPIApplicationBuilder): 149 | raise TypeError("You passed on invalid builder") 150 | self._builder = builder 151 | 152 | @property 153 | def builder(self) -> AbstractFastAPIApplicationBuilder: 154 | return self._builder 155 | 156 | @builder.setter 157 | def builder(self, new_builder: AbstractFastAPIApplicationBuilder): 158 | self._builder = new_builder 159 | 160 | def build_app(self) -> FastAPI: 161 | self.builder.configure_routes() 162 | self.builder.setup_middlewares() 163 | self.builder.configure_application_state() 164 | self.builder.configure_templates() 165 | self.builder.configure_exception_handlers() 166 | # We run `configure_events(...)` in the end of configure method, because we need to pass to on_shutdown and 167 | # on_startup handlers configured application 168 | self.builder.configure_events() 169 | return self.builder.app 170 | 171 | 172 | __all__ = ("Director", "DevelopmentApplicationBuilder") 173 | -------------------------------------------------------------------------------- /src/utils/application_builder/builder_base.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | import fastapi_jinja 4 | from fastapi import FastAPI 5 | 6 | from src.config.config import Config 7 | 8 | 9 | class AbstractFastAPIApplicationBuilder(metaclass=abc.ABCMeta): 10 | app: FastAPI 11 | 12 | def __init__(self, config: Config) -> None: 13 | self._config = config 14 | 15 | @abc.abstractmethod 16 | def configure_openapi_schema(self) -> None: 17 | pass 18 | 19 | @abc.abstractmethod 20 | def setup_middlewares(self) -> None: 21 | pass 22 | 23 | @abc.abstractmethod 24 | def configure_routes(self) -> None: 25 | pass 26 | 27 | @abc.abstractmethod 28 | def configure_events(self) -> None: 29 | pass 30 | 31 | @abc.abstractmethod 32 | def configure_exception_handlers(self) -> None: 33 | pass 34 | 35 | @abc.abstractmethod 36 | def configure_application_state(self) -> None: 37 | pass 38 | 39 | def configure_templates(self) -> None: 40 | fastapi_jinja.global_init(self._config.server.templates_dir) 41 | -------------------------------------------------------------------------------- /src/utils/database_utils.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | if typing.TYPE_CHECKING: 4 | from src.services.database.repositories.base import Model 5 | 6 | T = typing.TypeVar("T") 7 | Dictionary = typing.TypeVar("Dictionary", bound=typing.Dict[typing.Any, typing.Any]) 8 | 9 | 10 | @typing.overload 11 | def manual_cast(result: typing.Any) -> "Model": ... 12 | 13 | 14 | @typing.overload 15 | def manual_cast(result: typing.Any, cast_type: typing.Type[T]) -> T: ... 16 | 17 | 18 | # noinspection PyUnusedLocal 19 | def manual_cast(result, cast_type=None): 20 | return result 21 | 22 | 23 | @typing.no_type_check 24 | def filter_payload(payload, exclude): 25 | return {k: v for k, v in payload.items() if k not in ['cls', 'self', *exclude] and v is not None} 26 | -------------------------------------------------------------------------------- /src/utils/endpoints_specs.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Any 3 | 4 | from fastapi import Body 5 | 6 | 7 | @dataclass 8 | class ProductBodySpec: 9 | item: Any = Body( 10 | ..., 11 | example={ 12 | "name": "Apple MacBook 15", 13 | "unit_price": 7000, 14 | "description": "Light and fast laptop", 15 | "size": "S", 16 | }, 17 | ) 18 | 19 | 20 | @dataclass 21 | class UserBodySpec: 22 | item: Any = Body( 23 | ..., 24 | example={ 25 | "first_name": "Gleb", 26 | "last_name": "Garanin", 27 | "username": "GLEF1X", 28 | "phone_number": "+7900232132", 29 | "email": "glebgar567@gmail.com", 30 | "password": "qwerty12345", 31 | "balance": 5, 32 | }, 33 | ) 34 | -------------------------------------------------------------------------------- /src/utils/exceptions.py: -------------------------------------------------------------------------------- 1 | class UserIsUnauthorized(Exception): 2 | def __init__(self, hint: str): 3 | self.hint = hint 4 | -------------------------------------------------------------------------------- /src/utils/gunicorn_app.py: -------------------------------------------------------------------------------- 1 | import multiprocessing 2 | from typing import Any, Dict, Optional 3 | 4 | from gunicorn.app.base import Application 5 | 6 | 7 | def number_of_workers() -> int: 8 | return (multiprocessing.cpu_count() * 2) + 1 9 | 10 | 11 | class StandaloneApplication(Application): 12 | def __init__(self, app: Any, options: Optional[Dict[Any, Any]] = None): 13 | self._options = options 14 | self._application = app 15 | super(StandaloneApplication, self).__init__() 16 | 17 | def load_config(self) -> None: 18 | config = {} 19 | if self._options: 20 | config = { 21 | key: value 22 | for key, value in self._options.items() 23 | if key in self.cfg.settings and value is not None 24 | } 25 | for key, value in config.items(): 26 | self.cfg.set(key.lower(), value) 27 | 28 | def load(self) -> Any: 29 | return self._application 30 | -------------------------------------------------------------------------------- /src/utils/logging.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import logging.config 3 | from typing import Callable, Union, Dict, Any, Optional 4 | 5 | import orjson 6 | import sentry_sdk 7 | import structlog 8 | from sqlalchemy import log as sa_log 9 | from structlog_sentry import SentryProcessor 10 | 11 | ProcessorType = Callable[ 12 | [ 13 | structlog.types.WrappedLogger, str, structlog.types.EventDict 14 | ], Union[str, bytes] 15 | ] 16 | 17 | 18 | @dataclasses.dataclass() 19 | class LoggingConfig: 20 | render_json_logs: bool = False 21 | disable_sqlalchemy_repetetive_logs: bool = True 22 | sentry_dsn: Optional[str] = None 23 | async_logger: bool = False 24 | 25 | 26 | class RenderProcessorFactory: 27 | 28 | def __init__(self, render_json_logs: bool = False, 29 | serializer: Callable[..., Union[str, bytes]] = orjson.dumps): 30 | self._render_json_logs = render_json_logs 31 | self._serializer = serializer 32 | 33 | def get_processor(self) -> ProcessorType: 34 | if self._render_json_logs: 35 | return structlog.processors.JSONRenderer(serializer=self._serializer) 36 | else: 37 | return structlog.dev.ConsoleRenderer() 38 | 39 | 40 | def configure_logging(cfg: LoggingConfig) -> Dict[str, Any]: 41 | if cfg.sentry_dsn is not None: 42 | sentry_sdk.init(cfg.sentry_dsn, traces_sample_rate=1.0) 43 | if cfg.disable_sqlalchemy_repetetive_logs: 44 | sa_log._add_default_handler = lambda _: None # type: ignore 45 | render_processor = RenderProcessorFactory(cfg.render_json_logs).get_processor() 46 | time_stamper = structlog.processors.TimeStamper(fmt="iso") 47 | pre_chain = [ 48 | structlog.stdlib.add_log_level, 49 | time_stamper, 50 | SentryProcessor(level=logging.ERROR), 51 | ] 52 | config = { 53 | "version": 1, 54 | "disable_existing_loggers": False, 55 | "formatters": { 56 | "colored": { 57 | "()": structlog.stdlib.ProcessorFormatter, 58 | "processors": [ 59 | structlog.stdlib.ProcessorFormatter.remove_processors_meta, 60 | render_processor, 61 | ], 62 | "foreign_pre_chain": pre_chain, 63 | }, 64 | }, 65 | "handlers": { 66 | "default": { 67 | "level": "DEBUG", 68 | "class": "logging.StreamHandler", 69 | "formatter": "colored", 70 | }, 71 | }, 72 | "loggers": { 73 | "": { 74 | "handlers": ["default"], 75 | "level": "DEBUG", 76 | "propagate": True, 77 | }, 78 | "gunicorn.access": {"handlers": ["default"]}, 79 | "gunicorn.error": {"handlers": ["default"]}, 80 | "uvicorn.access": {"handlers": ["default"]}, 81 | }, 82 | "root": { 83 | "level": "DEBUG", 84 | "handlers": ["default"] 85 | }, 86 | } 87 | logging.config.dictConfig(config) 88 | structlog.configure( 89 | processors=[ 90 | structlog.contextvars.merge_contextvars, 91 | structlog.stdlib.add_log_level, 92 | structlog.stdlib.PositionalArgumentsFormatter(), 93 | time_stamper, 94 | structlog.processors.StackInfoRenderer(), 95 | structlog.stdlib.ProcessorFormatter.wrap_for_formatter, 96 | ], 97 | logger_factory=structlog.stdlib.LoggerFactory(), 98 | wrapper_class=structlog.stdlib.AsyncBoundLogger if cfg.async_logger else structlog.stdlib.BoundLogger, # type: ignore # noqa 99 | cache_logger_on_first_use=True, 100 | ) 101 | return config 102 | -------------------------------------------------------------------------------- /src/utils/password_hashing/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/fastapi-project/21a4ad9835936d0dcf9de4d63ae1bed31604a6c8/src/utils/password_hashing/__init__.py -------------------------------------------------------------------------------- /src/utils/password_hashing/protocol.py: -------------------------------------------------------------------------------- 1 | from typing import Protocol 2 | 3 | 4 | class PasswordHasherProto(Protocol): 5 | 6 | def hash(self, password: str) -> str: ... 7 | 8 | def check_needs_rehash(self, hash: str) -> bool: ... 9 | 10 | def verify(self, hash: str, password: str) -> bool: ... -------------------------------------------------------------------------------- /src/utils/responses.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Type, Any, TypeVar, Union 2 | 3 | from fastapi.responses import ORJSONResponse 4 | from pydantic import ValidationError 5 | from starlette import status 6 | from starlette.background import BackgroundTask 7 | from starlette.responses import JSONResponse 8 | 9 | from src.resources import api_string_templates 10 | 11 | Model = TypeVar("Model") 12 | 13 | 14 | class BadRequestJsonResponse(ORJSONResponse): 15 | 16 | def __init__(self, 17 | content: Any = None, 18 | headers: dict = None, 19 | media_type: str = None, 20 | background: BackgroundTask = None, ): 21 | super(BadRequestJsonResponse, self).__init__( 22 | content=content, 23 | status_code=status.HTTP_400_BAD_REQUEST, 24 | headers=headers, 25 | media_type=media_type, 26 | background=background 27 | ) 28 | 29 | 30 | class NotFoundJsonResponse(ORJSONResponse): 31 | 32 | def __init__(self, 33 | content: Any = None, 34 | headers: dict = None, 35 | media_type: str = None, 36 | background: BackgroundTask = None, ): 37 | super(NotFoundJsonResponse, self).__init__( 38 | content=content, 39 | status_code=status.HTTP_404_NOT_FOUND, 40 | headers=headers, 41 | media_type=media_type, 42 | background=background 43 | ) 44 | 45 | 46 | def get_pydantic_model_or_return_raw_response( 47 | model: Type[Model], db_obj: Optional[Any] = None 48 | ) -> Union[ORJSONResponse, Model]: 49 | if db_obj is None: 50 | return NotFoundJsonResponse(content=api_string_templates.OBJECT_NOT_FOUND) 51 | try: 52 | return model.from_orm(db_obj) # type: ignore 53 | except ValidationError: 54 | return BadRequestJsonResponse() 55 | -------------------------------------------------------------------------------- /src/views/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from fastapi import APIRouter, FastAPI 4 | 5 | from .home import api_router 6 | 7 | 8 | def setup_routes(main_router: Union[FastAPI, APIRouter], include_in_schema: bool = False): 9 | main_router.include_router(api_router, include_in_schema=include_in_schema) 10 | -------------------------------------------------------------------------------- /src/views/home.py: -------------------------------------------------------------------------------- 1 | import fastapi_jinja 2 | from fastapi import Header, APIRouter 3 | from starlette.requests import Request 4 | from starlette.responses import HTMLResponse, Response 5 | 6 | api_router = APIRouter() 7 | 8 | 9 | @api_router.get("/", response_class=HTMLResponse, include_in_schema=False) 10 | @fastapi_jinja.template("home/index.html") 11 | async def index(request: Request, user_agent: str = Header(...)): 12 | return {"user_name": "GLEF1X", "request": request} 13 | 14 | 15 | @api_router.post("/cookie", response_description="Test", tags=["Test"], include_in_schema=False) 16 | async def cookie_test(response: Response): 17 | response.set_cookie("fake_session", "fake_session_data", expires=60, max_age=120) 18 | response.headers["X-Cat-Dog"] = "alone in the wolrd" 19 | return {"message": "Come to the dark side, we have cookies"} 20 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/fastapi-project/21a4ad9835936d0dcf9de4d63ae1bed31604a6c8/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | In conftest.py I mark most scope of fixtures as "module", 3 | because performance if migrations will be executed for each function(by default) will be poor. 4 | """ 5 | import asyncio 6 | from typing import cast, Any, AsyncGenerator 7 | 8 | import pytest 9 | from alembic import config as alembic_config, command 10 | from asgi_lifespan import LifespanManager 11 | from fastapi import FastAPI 12 | from httpx import AsyncClient, Headers 13 | from sqlalchemy.orm import sessionmaker 14 | 15 | from src.core import ApplicationSettings 16 | from src.services.database import User, Product 17 | from src.services.database.models import SizeEnum 18 | from src.services.database.repositories.product_repository import ProductRepository 19 | from src.services.database.repositories.user_repository import UserRepository 20 | from src.utils import jwt 21 | from src.utils.application_builder.api_installation import DevelopmentApplicationBuilder, Director 22 | 23 | 24 | @pytest.fixture(scope="module") 25 | def event_loop(): 26 | loop = asyncio.get_event_loop() 27 | yield loop 28 | loop.close() 29 | 30 | 31 | @pytest.fixture(scope="module") 32 | def path_to_alembic_ini() -> str: 33 | from src.core import BASE_DIR # local import only for tests 34 | return str(BASE_DIR / "alembic.ini") 35 | 36 | 37 | @pytest.fixture(scope="module") 38 | def path_to_migrations_folder() -> str: 39 | from src.core import BASE_DIR # local import only for tests 40 | return str(BASE_DIR / "src" / "services" / "database" / "migrations") 41 | 42 | 43 | @pytest.fixture(scope="module") 44 | async def apply_migrations(path_to_alembic_ini: str, path_to_migrations_folder: str) -> AsyncGenerator[None, Any]: 45 | alembic_cfg = alembic_config.Config(path_to_alembic_ini) 46 | alembic_cfg.set_main_option('script_location', path_to_migrations_folder) 47 | command.upgrade(alembic_cfg, 'head') 48 | yield 49 | command.downgrade(alembic_cfg, 'base') 50 | 51 | 52 | @pytest.fixture(scope="module") 53 | def app(apply_migrations: None) -> FastAPI: 54 | settings = ApplicationSettings() 55 | director = Director(DevelopmentApplicationBuilder(settings=settings)) 56 | return director.build_app() 57 | 58 | 59 | @pytest.fixture(scope="module") 60 | async def initialized_app(app: FastAPI) -> AsyncGenerator[FastAPI, Any]: 61 | async with LifespanManager(app): 62 | yield app 63 | 64 | 65 | @pytest.fixture(scope="module") 66 | def session_maker(initialized_app: FastAPI) -> sessionmaker: # type: ignore 67 | return initialized_app.state.db_components.sessionmaker # type: ignore 68 | 69 | 70 | @pytest.fixture(scope="module") 71 | async def client(initialized_app: FastAPI) -> AsyncGenerator[AsyncClient, Any]: 72 | async with AsyncClient( 73 | app=initialized_app, 74 | base_url="http://test", 75 | headers={"Content-Type": "application/json"}, 76 | ) as client: # type: AsyncClient 77 | yield client 78 | 79 | 80 | @pytest.fixture(name="test_user", scope="module") 81 | async def user_for_test(session_maker: sessionmaker) -> User: # type: ignore 82 | repository = UserRepository(session_maker) 83 | return await repository.add_user(email="test@test.com", password="password", username="username", 84 | first_name="Gleb", last_name="Garanin", phone_number="+7657676556", 85 | balance=666) 86 | 87 | 88 | @pytest.fixture(name="test_product", scope="module") 89 | async def product_for_test(session_maker: sessionmaker) -> Product: # type: ignore 90 | product_repository = ProductRepository(session_maker) 91 | return await product_repository.add_product( 92 | name="Pencil", 93 | unit_price=50.00, 94 | size=SizeEnum.SMALL 95 | ) 96 | 97 | 98 | @pytest.fixture(scope="module") 99 | def token(test_user: User) -> str: 100 | return jwt.create_access_token_for_user(test_user) 101 | 102 | 103 | @pytest.fixture(name="settings", scope="module") 104 | def application_settings_fixture(app: FastAPI) -> ApplicationSettings: 105 | return cast(ApplicationSettings, app.state.settings) 106 | 107 | 108 | @pytest.fixture(scope="module") 109 | def authorized_client(client: AsyncClient, token: str) -> AsyncClient: 110 | client.headers = Headers({"Authorization": f"Bearer {token}"}) 111 | return client 112 | -------------------------------------------------------------------------------- /tests/test_api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GLEF1X/fastapi-project/21a4ad9835936d0dcf9de4d63ae1bed31604a6c8/tests/test_api/__init__.py -------------------------------------------------------------------------------- /tests/test_api/test_oauth.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import FastAPI 3 | from httpx import AsyncClient 4 | 5 | from src.services.database import User 6 | 7 | pytestmark = pytest.mark.asyncio 8 | 9 | 10 | async def test_login(authorized_client: AsyncClient, app: FastAPI, test_user: User) -> None: 11 | response = await authorized_client.post( 12 | app.url_path_for("oauth:login"), 13 | data={"username": test_user.username, "password": "password"} 14 | ) 15 | assert response.status_code == 200 16 | -------------------------------------------------------------------------------- /tests/test_api/test_products.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import FastAPI 3 | from httpx import AsyncClient 4 | 5 | from src.api.v1.dto import ProductDTO 6 | from src.services.database.models import SizeEnum 7 | 8 | pytestmark = pytest.mark.asyncio 9 | 10 | 11 | async def test_create_product(authorized_client: AsyncClient, app: FastAPI) -> None: 12 | product = ProductDTO(name="blouse", unit_price=50.00, description="Pretty blouse", size=SizeEnum.SMALL) 13 | product.patch_enum_values() 14 | response = await authorized_client.put(app.url_path_for("products:create_product"), json=product.dict()) 15 | assert response.status_code == 201 16 | -------------------------------------------------------------------------------- /tests/test_api/test_users.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi import FastAPI 3 | from httpx import AsyncClient 4 | 5 | from src.services.database import User 6 | 7 | pytestmark = pytest.mark.asyncio 8 | 9 | 10 | async def test_create_user(authorized_client: AsyncClient, app: FastAPI) -> None: 11 | response = await authorized_client.put( 12 | app.url_path_for("users:create_user"), 13 | json={ 14 | "first_name": "Gleb", 15 | "last_name": "Garanin", 16 | "username": "GLEF1X", 17 | "phone_number": "+7900232132", 18 | "email": "glebgar567@gmail.com", 19 | "password": "qwerty12345", 20 | "balance": 5, 21 | }, 22 | ) 23 | assert response.status_code == 200 24 | assert response.json().get("success") is True 25 | 26 | 27 | async def test_users_count(authorized_client: AsyncClient, app: FastAPI) -> None: 28 | response = await authorized_client.post(app.url_path_for("users:get_users_count")) 29 | assert response.status_code == 200 30 | assert isinstance(response.json().get("count"), int) 31 | 32 | 33 | async def test_get_user_info(authorized_client: AsyncClient, app: FastAPI, test_user: User) -> None: 34 | response = await authorized_client.get(app.url_path_for("users:get_user_info", user_id=str(test_user.id))) 35 | assert response.status_code == 200 36 | 37 | 38 | async def test_get_all_users(authorized_client: AsyncClient, app: FastAPI) -> None: 39 | response = await authorized_client.get(app.url_path_for("users:get_all_users")) 40 | assert response.status_code == 200 41 | 42 | 43 | async def test_delete_user(authorized_client: AsyncClient, app: FastAPI, test_user: User) -> None: 44 | response = await authorized_client.delete(app.url_path_for("users:delete_user", user_id=str(test_user.id))) 45 | assert response.status_code == 200 46 | assert response.json() == {"message": f"UserDTO with id {test_user.id} was successfully deleted from database"} 47 | -------------------------------------------------------------------------------- /tests/test_services/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021. Lorem ipsum dolor sit amet, consectetur adipiscing elit. 2 | # Morbi non lorem porttitor neque feugiat blandit. Ut vitae ipsum eget quam lacinia accumsan. 3 | # Etiam sed turpis ac ipsum condimentum fringilla. Maecenas magna. 4 | # Proin dapibus sapien vel ante. Aliquam erat volutpat. Pellentesque sagittis ligula eget metus. 5 | # Vestibulum commodo. Ut rhoncus gravida arcu. 6 | 7 | from __future__ import annotations 8 | --------------------------------------------------------------------------------