Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FastAPI Template: Основа #3

Merged
merged 15 commits into from
Sep 22, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/fastapi/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.venv
.env
10 changes: 10 additions & 0 deletions src/fastapi/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
db_host=db
db_port=5432
db_user=user
db_password=Qwe123
db_name=notification_admin

server_host=0.0.0.0
server_port=8000

jwt_secret_key=v9LctjUWwol4XbvczPiLFMDtZ8aal7mm
15 changes: 15 additions & 0 deletions src/fastapi/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
FROM python:3.11

WORKDIR /opt/app

ENV PYTHONPATH '/opt/app'

COPY pyproject.toml ./
RUN pip install poetry \
&& poetry config virtualenvs.create false \
&& poetry install --no-dev

COPY bin bin
COPY lib lib
COPY entrypoint.sh .
RUN chmod +x /opt/app/entrypoint.sh
3 changes: 3 additions & 0 deletions src/fastapi/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
include ../../common_makefile.mk

PROJECT_FOLDERS = bin lib tests
Empty file added src/fastapi/bin/__init__.py
Empty file.
17 changes: 17 additions & 0 deletions src/fastapi/bin/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import logging

import uvicorn

import lib.app.app as app_module
from lib.app import settings as libs_app_settings

logger = logging.getLogger(__name__)


app_instance = app_module.Application()
app = app_instance.create_app()
settings = libs_app_settings.get_settings()


if __name__ == "__main__":
uvicorn.run(app, host=settings.api.host, port=settings.api.port)
64 changes: 64 additions & 0 deletions src/fastapi/docker-compose.dev.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
version: '3'

services:
db:
image: postgres:15.2
environment:
POSTGRES_USER: ${db_user}
POSTGRES_PASSWORD: ${db_password}
POSTGRES_DB: ${db_name}
env_file:
- .env
ports:
- "${db_port}:5432"
ijaric marked this conversation as resolved.
Show resolved Hide resolved
volumes:
- postgres_data:/var/lib/postgresql/data/
restart: always
networks:
- backend_network

api:
build:
context: .
container_name: fastapi
image: fastapi_app
restart: always
entrypoint: ["/opt/app/entrypoint.sh"]
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

У тебя entrypoint есть в Dockerfile. Насколько я понимаю, тогда здесь это не нужно.

ports:
- "${server_port}:5432"
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Здесь тоже, пожалуй, будет - "${server_port}:${server_port}".

depends_on:
- db
env_file:
- .env
networks:
- backend_network
- api_network

nginx:
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Я бы сделал так. Мы с Сережей научились использовать шаблоны и передавать переменные и шаблоны для nginx (пример).

  nginx:
    image: nginx:1.25.1
    restart: always
    env_file:
      - .env
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/templates:/etc/nginx/templates
    ports:
      - "${NGINX_PORT}:${NGINX_PORT}"
    depends_on:
      - api

image: nginx:1.23.4
volumes:
- ./nginx:/etc/nginx/:ro
depends_on:
- api
ports:
- "80:80"
networks:
- api_network

redis:
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Я бы убрал Redis :)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

На данном этапе можно убрать, но он понадобится при построении API на этапе кеширования ответов

image: redis:7.0.11
restart: always
command: redis-server --bind 0.0.0.0
ports:
- "6379:6379"
networks:
- backend_network

volumes:
postgres_data:

networks:
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Подскажи зачем нам нужно две сети?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Чтобы разделять, кому нужно видеть бекенд, а кому нет. Тому же nginx не нужно видеть, что у нас есть база данных, и наоборот

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Возможно.
Только будут пограничные случаи. Например, jaeger, будет работать внутри, но один порт у него должен быть открыт наружу. То же самое с сервисами kibana итп.

api_network:
driver: bridge
backend_network:
driver: bridge
60 changes: 60 additions & 0 deletions src/fastapi/docker-compose.yml
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Аналогичные комментарии как к dev.

Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
version: '3'

services:
db:
image: postgres:15.2
environment:
POSTGRES_USER: ${db_user}
POSTGRES_PASSWORD: ${db_password}
POSTGRES_DB: ${db_name}
env_file:
- .env
ports:
- "127.0.0.1:${db_port}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data/
restart: always
networks:
- backend_network

api:
build:
context: .
container_name: fastapi
image: fastapi_app
restart: always
entrypoint: ["/opt/app/entrypoint.sh"]
depends_on:
- db
env_file:
- .env
networks:
- backend_network
- api_network

nginx:
image: nginx:1.23.4
volumes:
- ./nginx:/etc/nginx/:ro
depends_on:
- api
ports:
- "80:80"
networks:
- api_network

redis:
image: redis:7.0.11
restart: always
command: redis-server --bind 0.0.0.0
networks:
- backend_network

volumes:
postgres_data:

networks:
api_network:
driver: bridge
backend_network:
driver: bridge
7 changes: 7 additions & 0 deletions src/fastapi/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/bin/bash

while ! (echo > /dev/tcp/db/5432) >/dev/null 2>&1; do
sleep 1
done

exec python -m bin
Empty file added src/fastapi/lib/__init__.py
Empty file.
Empty file added src/fastapi/lib/api/__init__.py
Empty file.
Empty file.
Empty file.
26 changes: 26 additions & 0 deletions src/fastapi/lib/api/models/mixins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import uuid

import sqlalchemy
from sqlalchemy import Column, DateTime
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Здесь и далее я бы использовал прямые импорты. Например,

import sqlalchemy
...
return sqlalchemy.Column(...)

from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.ext.declarative import declared_attr


class BaseMixin:
@declared_attr
def id(cls):
return Column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
unique=True,
nullable=False,
)

@declared_attr
def created_at(cls):
return Column(DateTime, server_default=sqlalchemy.func.now())
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Интернет говорит, что должно быть sqlalchemy.sql.func.now(). Надо бы перепроверить в документации SQLAlchemy (она у меня сейчас нормально не открывается).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Возможно, но данная конструкция работала раньше. Если будет работать sqlalchemy.sql.func.now(), то добавлю её


@declared_attr
def updated_at(cls):
return Column(DateTime, server_default=sqlalchemy.func.now())
Empty file.
8 changes: 8 additions & 0 deletions src/fastapi/lib/api/schemas/entity.py
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Если мы делаем schemas в API, то может сразу добавим версионирование /lib/api/v1/schemas/?

Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import uuid

import pydantic


class Token(pydantic.BaseModel):
sub: uuid.UUID
exp: int | None = None
Empty file.
27 changes: 27 additions & 0 deletions src/fastapi/lib/api/services/token.py
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Не совсем понимаю смысл этого файла. Можешь объяснить, пожалуйста, и зачем нам в сервисе app = FastAPI()?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Это заготовка под проверку токена при запросе к API. В одном из ревью ревьювер указал на необходимость использования авторизации по токену.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

В моём понимание, это должна быть middleware. Например, здесь проверяем наличие header'а X-Request-Id и без него не пускаем дальше. Здесь я обновлял токен, что точно не нужно. Однако, там есть часть с проверкой самого токена (но в целом, это не самый удачный пример).

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PS: Я бы даже вынес это в отдельный PR.

Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from jose import JWTError, jwt
from pydantic import ValidationError

from fastapi import FastAPI, HTTPException, Security, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from lib.api.schemas.entity import Token
from lib.app import settings as lib_app_settings

app = FastAPI()
settings = lib_app_settings.get_settings()

security = HTTPBearer()


def get_token_data(
authorization: HTTPAuthorizationCredentials = Security(security),
) -> Token:
token = authorization.credentials
try:
secret_key = settings.jwt_secret_key
payload = jwt.decode(token, secret_key, algorithms=["HS256"])
return Token(**payload)
except (JWTError, ValidationError):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
)
Empty file added src/fastapi/lib/app/__init__.py
Empty file.
40 changes: 40 additions & 0 deletions src/fastapi/lib/app/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import logging
import logging.config as logging_config

import fastapi
import lib.api.handlers as admin_api_handlers

from .logger import LOGGING
from .settings import get_settings

logging_config.dictConfig(LOGGING)
logger = logging.getLogger(__name__)


class Application:
def __init__(self) -> None:
self.settings = get_settings()
self.logger = logging.getLogger(__name__)
self.producer = None

def create_app(self) -> fastapi.FastAPI:
app = fastapi.FastAPI(
title="FastAPI",
version="0.1.0",
docs_url="/api/openapi",
openapi_url="/api/openapi.json",
default_response_class=fastapi.responses.ORJSONResponse,
)

# app.include_router(admin_api_handlers.user_router, prefix="/api/v1/users", tags=["users"])
# app.include_router(admin_api_handlers.movie_router, prefix="/api/v1/movies", tags=["movies"])

@app.on_event("startup")
async def startup_event():
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Нам не нужно здесь инициализировать postgres клиента?

self.logger.info("Starting server")

@app.on_event("shutdown")
async def shutdown_event():
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

А здесь удалять postgres клиента?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Нужно, не доглядел

self.logger.info("Shutting down server")

return app
69 changes: 69 additions & 0 deletions src/fastapi/lib/app/logger.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import pydantic_settings


class LoggingSettings(pydantic_settings.BaseSettings):
log_format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
log_default_handlers: list[str] = [
"console",
]

log_level_handlers: str = "DEBUG"
log_level_loggers: str = "INFO"
log_level_root: str = "INFO"


log_settings = LoggingSettings()


LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"verbose": {"format": log_settings.log_format},
"default": {
"()": "uvicorn.logging.DefaultFormatter",
"fmt": "%(levelprefix)s %(message)s",
"use_colors": None,
},
"access": {
"()": "uvicorn.logging.AccessFormatter",
"fmt": "%(levelprefix)s %(client_addr)s - '%(request_line)s' %(status_code)s",
},
},
"handlers": {
"console": {
"level": log_settings.log_level_handlers,
"class": "logging.StreamHandler",
"formatter": "verbose",
},
"default": {
"formatter": "default",
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
},
"access": {
"formatter": "access",
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
},
},
"loggers": {
"": {
"handlers": log_settings.log_default_handlers,
"level": log_settings.log_level_loggers,
},
"uvicorn.error": {
"level": log_settings.log_level_loggers,
},
"uvicorn.access": {
"handlers": ["access"],
"level": log_settings.log_level_loggers,
"propagate": False,
},
},
"root": {
"level": log_settings.log_level_root,
"formatter": "verbose",
"handlers": log_settings.log_default_handlers,
},
}
36 changes: 36 additions & 0 deletions src/fastapi/lib/app/settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import functools

import pydantic_settings
from dotenv import load_dotenv
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Я бы убрал load_dotenv совсем. Давайте лучше укажем здесь значения по умолчанию (для локальной разработки), а в .env будут настройки для prod'а.

from pydantic import Field

load_dotenv(".env.dev")


class DbSettings(pydantic_settings.BaseSettings):
model_config = pydantic_settings.SettingsConfigDict(env_prefix="db_")

host: str = "localhost"
port: int = 5432
user: str
password: str
name: str


class ApiSettings(pydantic_settings.BaseSettings):
model_config = pydantic_settings.SettingsConfigDict(env_prefix="server_")

host: str = "0.0.0.0"
port: int = 8000


class Settings(pydantic_settings.BaseSettings):
db: DbSettings = Field(default_factory=DbSettings)
api: ApiSettings = Field(default_factory=ApiSettings)

jwt_secret_key: str


@functools.lru_cache
def get_settings() -> Settings:
return Settings()
Empty file added src/fastapi/lib/db/__init__.py
Empty file.
Loading
Loading