Skip to content

Commit

Permalink
Create basic structure for application (#13)
Browse files Browse the repository at this point in the history
  • Loading branch information
PavelErsh authored Mar 16, 2024
1 parent 6616df0 commit 14e5f32
Show file tree
Hide file tree
Showing 18 changed files with 1,005 additions and 40 deletions.
785 changes: 757 additions & 28 deletions async_api/poetry.lock

Large diffs are not rendered by default.

32 changes: 21 additions & 11 deletions async_api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,16 @@ name = "async_api"

[tool.poetry.dependencies] # https://python-poetry.org/docs/dependency-specification/
python = "^3.12"
redis = "^5.0.3"
elasticsearch = { extras = ["async"], version = "^8.12.1" }
fastapi = "^0.110.0"

pydantic = "^2.6.4"
uvicorn = "^0.28.0"
uvloop = "^0.19.0"

[tool.poetry.group.dev.dependencies] # https://python-poetry.org/docs/master/managing-dependencies/
black = "^24.2.0"
black = "^24.3.0"
mypy = "^1.8.0"
pre-commit = "^3.6.2"
pytest = "^8.0.2"
Expand All @@ -33,10 +40,9 @@ show_error_codes = true
show_error_context = true
show_traceback = true
color_output = true
# Uncomment this if you are using pydantic
#plugins = [
# "pydantic.mypy"
#]
plugins = [
"pydantic.mypy"
]

strict = true # https://mypy.readthedocs.io/en/stable/existing_code.html#introduce-stricter-options
ignore_missing_imports = true
Expand All @@ -46,20 +52,20 @@ module = "tests.*"
disallow_untyped_defs = false
disallow_incomplete_defs = false

# Uncomment this if you are using pydantic
#[tool.pydantic-mypy]
[tool.pydantic-mypy]
## https://pydantic-docs.helpmanual.io/mypy_plugin/#configuring-the-plugin
#init_forbid_extra = true
#init_typed = true
#warn_required_dynamic_aliases = true
#warn_untyped_fields = true
init_forbid_extra = true
init_typed = true
warn_required_dynamic_aliases = true
warn_untyped_fields = true

[tool.pytest.ini_options]
# https://docs.pytest.org/en/6.2.x/customize.html#pyproject-toml
# https://docs.pytest.org/en/latest/reference/reference.html#ini-options-ref
filterwarnings = ["error", "ignore::DeprecationWarning", "ignore::ImportWarning"]
testpaths = ["src", "tests"]
xfail_strict = true
pythonpath = ["src"]

# Extra options:
addopts = [
Expand Down Expand Up @@ -137,6 +143,10 @@ unfixable = [
[tool.ruff.lint.flake8-tidy-imports] # https://docs.astral.sh/ruff/settings/#flake8-tidy-imports
ban-relative-imports = "all"

[tool.ruff.lint.flake8-bugbear]
# Allow default arguments like, e.g., `data: List[str] = fastapi.Query(None)`.
extend-immutable-calls = ["fastapi.Depends", "fastapi.Query"]

[tool.ruff.lint.isort] # https://docs.astral.sh/ruff/settings/#isort
section-order = ["future", "typing", "standard-library", "third-party", "first-party", "local-folder"]
lines-between-types = 1
Expand Down
13 changes: 13 additions & 0 deletions async_api/src/Redme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
## 📔 api
Модуль, в котором реализуется API, предоставления http-интерфейса клиентским приложениям. Внутри модуля отсутствует какая-либо бизнес-логика, так как она не должна быть завязана на HTTP.
## 📔 core
Cодержит разные конфигурационные файлы.
## 📔 db
Предоставляет объекты баз данных
(Redis, Elasticsearch) и провайдеры для внедрения зависимостей.
Redis будет использоваться для кеширования, чтобы не нагружать лишний раз Elasticsearch.

## 📔 models
Содержит классы, описывающие бизнес-сущности, например, фильмы, жанры, актёров.
## 📔 services
Главное в сервисе. В этом модуле находится реализация всей бизнес-логики.
Empty file added async_api/src/api/__init__.py
Empty file.
Empty file.
20 changes: 20 additions & 0 deletions async_api/src/api/v1/films.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from http import HTTPStatus

from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from services.film import FilmService, get_film_service

router = APIRouter()


class Film(BaseModel):
id: str
title: str


@router.get("/{film_id}", response_model=Film)
async def film_details(film_id: str, film_service: FilmService = Depends(get_film_service)) -> Film:
film = await film_service.get_by_id(film_id)
if not film:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="film not found")
return Film(id=film.id, title=film.title)
Empty file added async_api/src/core/__init__.py
Empty file.
19 changes: 19 additions & 0 deletions async_api/src/core/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import os

from logging import config as logging_config
from pathlib import Path

from core.logger import LOGGING

logging_config.dictConfig(LOGGING)

PROJECT_NAME = os.getenv("PROJECT_NAME", "movies")

REDIS_HOST = os.getenv("REDIS_HOST", "127.0.0.1")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))

ELASTIC_HOST = os.getenv("ELASTIC_HOST", "127.0.0.1")
ELASTIC_PORT = int(os.getenv("ELASTIC_PORT", "9200"))
ELASTIC_SCHEME = "http"

BASE_DIR = Path(__file__).resolve().parent.parent
58 changes: 58 additions & 0 deletions async_api/src/core/logger.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
LOG_DEFAULT_HANDLERS = ["console"]


LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"verbose": {
"format": 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": "DEBUG",
"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_DEFAULT_HANDLERS,
"level": "INFO",
},
"uvicorn.error": {
"level": "INFO",
},
"uvicorn.access": {
"handlers": ["access"],
"level": "INFO",
"propagate": False,
},
},
"root": {
"level": "INFO",
"formatter": "verbose",
"handlers": LOG_DEFAULT_HANDLERS,
},
}
Empty file added async_api/src/db/__init__.py
Empty file.
10 changes: 10 additions & 0 deletions async_api/src/db/elastic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from core import config
from elasticsearch import AsyncElasticsearch

es = AsyncElasticsearch(
[{"host": config.ELASTIC_HOST, "port": config.ELASTIC_PORT, "scheme": config.ELASTIC_SCHEME}],
)


async def get_elastic() -> AsyncElasticsearch:
return es
8 changes: 8 additions & 0 deletions async_api/src/db/redis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from core import config
from redis.asyncio import Redis

redis = Redis(host=config.REDIS_HOST, port=config.REDIS_PORT)


async def get_redis() -> Redis:
return redis
37 changes: 37 additions & 0 deletions async_api/src/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import os

import uvicorn

from api.v1 import films
from core import config
from db import elastic, redis
from fastapi import FastAPI
from fastapi.responses import ORJSONResponse

app = FastAPI(
title=config.PROJECT_NAME,
root_path="/api",
default_response_class=ORJSONResponse,
)


@app.on_event("startup")
async def startup() -> None:
await redis.redis.initialize()
await elastic.es.info()


@app.on_event("shutdown")
async def shutdown() -> None:
await redis.redis.close()
await elastic.es.close()


app.include_router(films.router, prefix="/v1/films", tags=["films"])

if __name__ == "__main__":
uvicorn.run(
"main:app",
host=str(os.getenv("HOST")),
port=8000,
)
Empty file.
10 changes: 10 additions & 0 deletions async_api/src/models/film.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from pydantic import BaseModel


class Film(BaseModel):
id: str
title: str
description: str

class Config:
pass
Empty file.
51 changes: 51 additions & 0 deletions async_api/src/services/film.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
from functools import lru_cache

from db.elastic import get_elastic
from db.redis import get_redis
from elasticsearch import AsyncElasticsearch, NotFoundError
from fastapi import Depends
from models.film import Film
from redis.asyncio import Redis

FILM_CACHE_EXPIRE_IN_SECONDS = 60 * 5


class FilmService:
def __init__(self, redis: Redis, elastic: AsyncElasticsearch):
self.redis = redis
self.elastic = elastic

async def get_by_id(self, film_id: str) -> Film | None:
film = await self._film_from_cache(film_id)
if not film:
film = await self._get_film_from_elastic(film_id)
if not film:
return None
await self._put_film_to_cache(film)

return film

async def _get_film_from_elastic(self, film_id: str) -> Film | None:
try:
doc = await self.elastic.get(index="movies", id=film_id)
except NotFoundError:
return None
return Film(**doc["_source"])

async def _film_from_cache(self, film_id: str) -> Film | None:
data = await self.redis.get(film_id)
if not data:
return None

return Film.parse_raw(data)

async def _put_film_to_cache(self, film: Film) -> None:
await self.redis.set(film.id, film.json(), FILM_CACHE_EXPIRE_IN_SECONDS)


@lru_cache
def get_film_service(
redis: Redis = Depends(get_redis),
elastic: AsyncElasticsearch = Depends(get_elastic),
) -> FilmService:
return FilmService(redis, elastic)
2 changes: 1 addition & 1 deletion async_api/tests/test_example/test_hello.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import pytest

from src.example import hello
from example import hello


@pytest.mark.parametrize(
Expand Down

0 comments on commit 14e5f32

Please sign in to comment.