Skip to content

Commit

Permalink
Abstract classes (#55)
Browse files Browse the repository at this point in the history
  • Loading branch information
PavelErsh authored Apr 4, 2024
1 parent c76fbb3 commit 3b1c093
Show file tree
Hide file tree
Showing 9 changed files with 133 additions and 38 deletions.
8 changes: 4 additions & 4 deletions async_api/src/api/v1/films.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from core.settings import settings
from models.film import Film
from models.value_objects import FilmID
from services.film import FilmService
from services.film import BaseFilmService, ElasticsearchFilmService

router = APIRouter()

Expand All @@ -22,7 +22,7 @@
)
@cache(expire=settings.cache_ttl_seconds)
async def get_film_list(
film_service: Annotated[FilmService, Depends()],
film_service: Annotated[BaseFilmService, Depends(ElasticsearchFilmService)],
pagination_params: Annotated[PaginationParams, Depends()],
sort_params: Annotated[SortParams, Depends()],
genre: str | None = None,
Expand All @@ -45,7 +45,7 @@ async def get_film_list(
)
@cache(expire=settings.cache_ttl_seconds)
async def search_films(
film_service: Annotated[FilmService, Depends()],
film_service: Annotated[BaseFilmService, Depends(ElasticsearchFilmService)],
pagination_params: Annotated[PaginationParams, Depends()],
query: str | None = None,
) -> list[Film]:
Expand All @@ -66,7 +66,7 @@ async def search_films(
@cache(expire=settings.cache_ttl_seconds)
async def get_film_details(
film_id: FilmID,
film_service: Annotated[FilmService, Depends()],
film_service: Annotated[BaseFilmService, Depends(ElasticsearchFilmService)],
) -> Film:
film = await film_service.get_or_none(film_id)
if not film:
Expand Down
6 changes: 3 additions & 3 deletions async_api/src/api/v1/genres.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from core.settings import settings
from models.genre import Genre
from models.value_objects import GenreID
from services.genre import GenreService
from services.genre import BaseGenreService, ElasticsearchGenreService

router = APIRouter()

Expand All @@ -22,7 +22,7 @@
@cache(expire=settings.cache_ttl_seconds)
async def get_genre_details(
genre_id: GenreID,
genre_service: Annotated[GenreService, Depends()],
genre_service: Annotated[BaseGenreService, Depends(ElasticsearchGenreService)],
) -> Genre:
genre = await genre_service.get_or_none(genre_id)
if not genre:
Expand All @@ -39,6 +39,6 @@ async def get_genre_details(
)
@cache(expire=settings.cache_ttl_seconds)
async def get_genres_list(
genre_service: Annotated[GenreService, Depends()],
genre_service: Annotated[BaseGenreService, Depends(ElasticsearchGenreService)],
) -> list[Genre]:
return await genre_service.get_list()
8 changes: 4 additions & 4 deletions async_api/src/api/v1/persons.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from core.settings import settings
from models.person import Person, PersonFilm
from models.value_objects import PersonID
from services.person import PersonService
from services.person import BasePersonService, ElasticsearchPersonService

router = APIRouter()

Expand All @@ -22,7 +22,7 @@
)
@cache(expire=settings.cache_ttl_seconds)
async def search_persons(
person_service: Annotated[PersonService, Depends()],
person_service: Annotated[BasePersonService, Depends(ElasticsearchPersonService)],
pagination_params: Annotated[PaginationParams, Depends()],
query: str | None = None,
) -> list[Person]:
Expand All @@ -43,7 +43,7 @@ async def search_persons(
@cache(expire=settings.cache_ttl_seconds)
async def get_person_details(
person_id: PersonID,
person_service: Annotated[PersonService, Depends()],
person_service: Annotated[BasePersonService, Depends(ElasticsearchPersonService)],
) -> Person:
person = await person_service.get_or_none(person_id)
if not person:
Expand All @@ -61,7 +61,7 @@ async def get_person_details(
@cache(expire=settings.cache_ttl_seconds)
async def get_person_films(
person_id: PersonID,
person_service: Annotated[PersonService, Depends()],
person_service: Annotated[BasePersonService, Depends(ElasticsearchPersonService)],
) -> list[PersonFilm]:
person = await person_service.get_or_none(person_id)
if not person:
Expand Down
3 changes: 2 additions & 1 deletion async_api/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@
from core.settings import settings
from db.elastic import elasticsearch
from db.redis import redis
from utils.cache import key_builder


@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
await redis.initialize()
await elasticsearch.info()
FastAPICache.init(RedisBackend(redis), prefix="fastapi-cache")
FastAPICache.init(RedisBackend(redis), prefix="fastapi-cache", key_builder=key_builder)
yield
await redis.close()
await elasticsearch.close()
Expand Down
60 changes: 46 additions & 14 deletions async_api/src/services/film.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from typing import Annotated

from dataclasses import dataclass
from abc import ABC, abstractmethod

from elasticsearch import AsyncElasticsearch, NotFoundError
from elasticsearch import AsyncElasticsearch
from elasticsearch.exceptions import NotFoundError
from fastapi import Depends

from core.settings import settings
Expand All @@ -11,9 +12,37 @@
from models.value_objects import FilmID, SortOrder


@dataclass
class FilmService:
elastic: Annotated[AsyncElasticsearch, Depends(get_elasticsearch)]
class BaseFilmService(ABC):
@abstractmethod
async def get_list(
self,
*,
page: int = 1,
size: int = settings.default_page_size,
sort_by: str | None = None,
sort_order: SortOrder | None = None,
genre: str | None = None,
) -> list[Film]:
pass

@abstractmethod
async def search(
self,
*,
query: str | None = None,
page: int = 1,
size: int = settings.default_page_size,
) -> list[Film]:
pass

@abstractmethod
async def get_or_none(self, film_id: FilmID) -> Film | None:
pass


class ElasticsearchFilmService(BaseFilmService):
def __init__(self, elastic: Annotated[AsyncElasticsearch, Depends(get_elasticsearch)]):
self.elastic = elastic

async def get_list(
self,
Expand All @@ -24,18 +53,22 @@ async def get_list(
sort_order: SortOrder | None = None,
genre: str | None = None,
) -> list[Film]:
query: dict[str, dict[str, dict[str, str | dict[str, str]]]]
query = {"match_all": {}}
if genre:
query = {"bool": {"filter": {"term": {"genres.name.keyword": genre}}}}

sort = None
if sort_by:
sort = {sort_by: {"order": sort_order or SortOrder.asc}}

result = await self.elastic.search(
index=settings.es_films_index,
from_=(page - 1) * size,
size=size,
query=(
{"bool": {"filter": {"term": {"genres.name.keyword": genre}}}}
if genre
else {"match_all": {}}
),
sort={sort_by: {"order": sort_order or SortOrder.asc}} if sort_by else None,
query=query,
sort=sort,
)

return [Film.model_validate(hit["_source"]) for hit in result["hits"]["hits"]]

async def search(
Expand All @@ -51,12 +84,11 @@ async def search(
size=size,
query={"match": {"title": query}} if query else {"match_all": {}},
)

return [Film.model_validate(hit["_source"]) for hit in result["hits"]["hits"]]

async def get_or_none(self, film_id: FilmID) -> Film | None:
try:
doc = await self.elastic.get(index=settings.es_films_index, id=str(film_id))
return Film.model_validate(doc["_source"])
except NotFoundError:
return None
return Film.model_validate(doc["_source"])
23 changes: 17 additions & 6 deletions async_api/src/services/genre.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from typing import Annotated

from dataclasses import dataclass
from abc import ABC, abstractmethod

from elasticsearch import AsyncElasticsearch, NotFoundError
from elasticsearch import AsyncElasticsearch
from elasticsearch.exceptions import NotFoundError
from fastapi import Depends

from core.settings import settings
Expand All @@ -11,16 +12,26 @@
from models.value_objects import GenreID


@dataclass
class GenreService:
elastic: Annotated[AsyncElasticsearch, Depends(get_elasticsearch)]
class BaseGenreService(ABC):
@abstractmethod
async def get_or_none(self, genre_id: GenreID) -> Genre | None:
pass

@abstractmethod
async def get_list(self) -> list[Genre]:
pass


class ElasticsearchGenreService(BaseGenreService):
def __init__(self, elastic: Annotated[AsyncElasticsearch, Depends(get_elasticsearch)]):
self.elastic = elastic

async def get_or_none(self, genre_id: GenreID) -> Genre | None:
try:
doc = await self.elastic.get(index=settings.es_genres_index, id=str(genre_id))
return Genre.model_validate(doc["_source"])
except NotFoundError:
return None
return Genre.model_validate(doc["_source"])

async def get_list(self) -> list[Genre]:
result = await self.elastic.search(
Expand Down
30 changes: 24 additions & 6 deletions async_api/src/services/person.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from typing import Annotated

from dataclasses import dataclass
from abc import ABC, abstractmethod

from elasticsearch import AsyncElasticsearch, NotFoundError
from elasticsearch import AsyncElasticsearch
from elasticsearch.exceptions import NotFoundError
from fastapi import Depends

from core.settings import settings
Expand All @@ -11,9 +12,25 @@
from models.value_objects import PersonID


@dataclass
class PersonService:
elastic: Annotated[AsyncElasticsearch, Depends(get_elasticsearch)]
class BasePersonService(ABC):
@abstractmethod
async def get_or_none(self, person_id: PersonID) -> Person | None:
pass

@abstractmethod
async def search(
self,
*,
query: str | None = None,
page: int = 1,
size: int = settings.default_page_size,
) -> list[Person]:
pass


class ElasticsearchPersonService(BasePersonService):
def __init__(self, elastic: Annotated[AsyncElasticsearch, Depends(get_elasticsearch)]):
self.elastic = elastic

async def get_or_none(self, person_id: PersonID) -> Person | None:
try:
Expand All @@ -29,10 +46,11 @@ async def search(
page: int = 1,
size: int = settings.default_page_size,
) -> list[Person]:
search_query = {"match": {"full_name": query}} if query else {"match_all": {}}
result = await self.elastic.search(
index=settings.es_persons_index,
from_=(page - 1) * size,
size=size,
query={"match": {"full_name": query}} if query else {"match_all": {}},
query=search_query,
)
return [Person.model_validate(hit["_source"]) for hit in result["hits"]["hits"]]
File renamed without changes.
33 changes: 33 additions & 0 deletions async_api/src/utils/cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from typing import Any

import hashlib

from collections.abc import Callable

from starlette.requests import Request
from starlette.responses import Response


def key_builder(
func: Callable[..., Any],
namespace: str | None = "",
request: Request | None = None, # noqa: ARG001
response: Response | None = None, # noqa: ARG001
args: tuple[Any] | None = None,
kwargs: dict[str, Any] | None = None,
) -> str:
"""Key builder for fastapi-cache which does not take into account service dependencies.
See: https://github.com/long2ice/fastapi-cache/issues/279
"""
from fastapi_cache import FastAPICache

if kwargs:
kwargs = {key: value for key, value in kwargs.items() if not key.endswith("_service")}
prefix = f"{FastAPICache.get_prefix()}:{namespace}:"
return (
prefix
+ hashlib.md5( # noqa: S324
f"{func.__module__}:{func.__name__}:{args}:{kwargs}".encode(),
).hexdigest()
)

0 comments on commit 3b1c093

Please sign in to comment.