diff --git a/async_api/src/api/v1/films.py b/async_api/src/api/v1/films.py index 72f9e6a..32982ae 100644 --- a/async_api/src/api/v1/films.py +++ b/async_api/src/api/v1/films.py @@ -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() @@ -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, @@ -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]: @@ -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: diff --git a/async_api/src/api/v1/genres.py b/async_api/src/api/v1/genres.py index 08c27a6..cdca604 100644 --- a/async_api/src/api/v1/genres.py +++ b/async_api/src/api/v1/genres.py @@ -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() @@ -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: @@ -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() diff --git a/async_api/src/api/v1/persons.py b/async_api/src/api/v1/persons.py index e6b7530..1f6678b 100644 --- a/async_api/src/api/v1/persons.py +++ b/async_api/src/api/v1/persons.py @@ -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() @@ -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]: @@ -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: @@ -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: diff --git a/async_api/src/main.py b/async_api/src/main.py index 2856997..cb6bb7a 100644 --- a/async_api/src/main.py +++ b/async_api/src/main.py @@ -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() diff --git a/async_api/src/services/film.py b/async_api/src/services/film.py index 43e016a..c2a4f4a 100644 --- a/async_api/src/services/film.py +++ b/async_api/src/services/film.py @@ -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 @@ -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, @@ -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( @@ -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"]) diff --git a/async_api/src/services/genre.py b/async_api/src/services/genre.py index 62fac9b..ffefcd6 100644 --- a/async_api/src/services/genre.py +++ b/async_api/src/services/genre.py @@ -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 @@ -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( diff --git a/async_api/src/services/person.py b/async_api/src/services/person.py index c681499..676e251 100644 --- a/async_api/src/services/person.py +++ b/async_api/src/services/person.py @@ -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 @@ -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: @@ -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"]] diff --git a/async_api/tests/conftest.py b/async_api/src/utils/__init__.py similarity index 100% rename from async_api/tests/conftest.py rename to async_api/src/utils/__init__.py diff --git a/async_api/src/utils/cache.py b/async_api/src/utils/cache.py new file mode 100644 index 0000000..d40c047 --- /dev/null +++ b/async_api/src/utils/cache.py @@ -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() + )