diff --git a/async_api/pyproject.toml b/async_api/pyproject.toml index ac4af03..5df0510 100644 --- a/async_api/pyproject.toml +++ b/async_api/pyproject.toml @@ -27,6 +27,7 @@ coverage = "^7.4.3" typeguard = "^4.1.5" ruff = "^0.3.0" safety = "<3.0.0" # Pinned to <3.0.0 due to https://github.com/pyupio/safety/issues/504 +pytest-asyncio = "^0.23.6" [tool.black] # https://black.readthedocs.io/en/stable/usage_and_configuration/the_basics.html#configuration-via-a-file target-version = ["py312"] @@ -70,6 +71,7 @@ filterwarnings = ["error", "ignore::DeprecationWarning", "ignore::ImportWarning" testpaths = ["src", "tests"] xfail_strict = true pythonpath = ["src"] +asyncio_mode = "auto" # Extra options: addopts = [ diff --git a/async_api/src/api/v1/films.py b/async_api/src/api/v1/films.py index 031f12e..1a324ff 100644 --- a/async_api/src/api/v1/films.py +++ b/async_api/src/api/v1/films.py @@ -1,4 +1,5 @@ from http import HTTPStatus +from uuid import UUID from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel @@ -8,7 +9,7 @@ class Film(BaseModel): - id: str + id: UUID title: str diff --git a/async_api/src/core/settings.py b/async_api/src/core/settings.py index 0fd1396..2354c6c 100644 --- a/async_api/src/core/settings.py +++ b/async_api/src/core/settings.py @@ -1,11 +1,24 @@ +from pathlib import Path + from pydantic import RedisDsn from pydantic_settings import BaseSettings +PROJECT_ROOT = Path(__file__).parent.parent.parent + class Settings(BaseSettings): api_project_name: str = "Movies" + + # cache redis_url: RedisDsn + + # elasticsearch elasticsearch_host: str + es_genres_index: str = "genres" + es_persons_index: str = "persons" + + # pagination + default_page_size: int = 50 -settings = Settings(_env_file=".env") +settings = Settings(_env_file=PROJECT_ROOT / ".env") diff --git a/async_api/src/db/elastic.py b/async_api/src/db/elastic.py index c4e9764..97db5b2 100644 --- a/async_api/src/db/elastic.py +++ b/async_api/src/db/elastic.py @@ -4,5 +4,5 @@ es = AsyncElasticsearch(settings.elasticsearch_host) -async def get_elastic() -> AsyncElasticsearch: +def get_elastic() -> AsyncElasticsearch: return es diff --git a/async_api/src/models/base.py b/async_api/src/models/base.py index 02392c6..14d675f 100644 --- a/async_api/src/models/base.py +++ b/async_api/src/models/base.py @@ -1,7 +1,9 @@ +from uuid import UUID + from pydantic import BaseModel class UUIDBase(BaseModel): """Модель для базового класса.""" - id: str + id: UUID diff --git a/async_api/src/models/genre.py b/async_api/src/models/genre.py index 087ba70..84742ca 100644 --- a/async_api/src/models/genre.py +++ b/async_api/src/models/genre.py @@ -5,7 +5,7 @@ class Genre(UUIDBase): """Модель для хранения информации жанре в кинопроизведении.""" name: str - description: str + description: str | None = None class GenreMinimal(UUIDBase): diff --git a/async_api/src/models/person.py b/async_api/src/models/person.py index 55cbcbc..4e9696f 100644 --- a/async_api/src/models/person.py +++ b/async_api/src/models/person.py @@ -1,5 +1,4 @@ from models.base import UUIDBase -from pydantic import Field class PersonFilmRoles(UUIDBase): @@ -9,7 +8,7 @@ class PersonFilmRoles(UUIDBase): class Person(UUIDBase): - """Модель для хранения актёра.""" + """Модель для хранения информации об актёре.""" - full_name: str = Field(alias="name") + full_name: str films: list[PersonFilmRoles] diff --git a/async_api/src/services/film.py b/async_api/src/services/film.py new file mode 100644 index 0000000..cf4476b --- /dev/null +++ b/async_api/src/services/film.py @@ -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(str(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) diff --git a/async_api/src/services/genre.py b/async_api/src/services/genre.py new file mode 100644 index 0000000..095c02b --- /dev/null +++ b/async_api/src/services/genre.py @@ -0,0 +1,37 @@ +from typing import Annotated, NewType + +from dataclasses import dataclass +from uuid import UUID + +from core.settings import settings +from db.elastic import get_elastic +from elasticsearch import AsyncElasticsearch, NotFoundError +from fastapi import Depends +from models.genre import Genre + +GenreID = NewType("GenreID", UUID) + + +@dataclass +class GenreService: + elastic: Annotated[AsyncElasticsearch, Depends(get_elastic)] + + async def get_by_id(self, genre_id: GenreID) -> Genre | None: + try: + doc = await self.elastic.get(index=settings.es_genres_index, id=str(genre_id)) + except NotFoundError: + return None + return Genre.model_validate(doc["_source"]) + + async def search( + self, + page: int = 1, + size: int = settings.default_page_size, + ) -> list[Genre]: + result = await self.elastic.search( + index=settings.es_genres_index, + from_=(page - 1) * size, + size=size, + query={"match_all": {}}, + ) + return [Genre.model_validate(hit["_source"]) for hit in result["hits"]["hits"]] diff --git a/async_api/src/services/person.py b/async_api/src/services/person.py new file mode 100644 index 0000000..597c2ac --- /dev/null +++ b/async_api/src/services/person.py @@ -0,0 +1,39 @@ +from typing import Annotated, NewType + +from dataclasses import dataclass +from uuid import UUID + +from core.settings import settings +from db.elastic import get_elastic +from elasticsearch import AsyncElasticsearch, NotFoundError +from fastapi import Depends +from models.person import Person + +PersonID = NewType("PersonID", UUID) + + +@dataclass +class PersonService: + elastic: Annotated[AsyncElasticsearch, Depends(get_elastic)] + + async def get_by_id(self, person_id: UUID) -> Person | None: + try: + doc = await self.elastic.get(index=settings.es_persons_index, id=str(person_id)) + except NotFoundError: + return None + return Person.model_validate(doc["_source"]) + + async def search( + self, + *, + query: str | None = None, + page: int = 1, + size: int = settings.default_page_size, + ) -> list[Person]: + 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": {}}, + ) + return [Person.model_validate(hit["_source"]) for hit in result["hits"]["hits"]] diff --git a/async_api/tests/conftest.py b/async_api/tests/conftest.py new file mode 100644 index 0000000..5b63233 --- /dev/null +++ b/async_api/tests/conftest.py @@ -0,0 +1,11 @@ +import pytest + +from core.settings import settings +from elasticsearch import AsyncElasticsearch + + +@pytest.fixture() +async def elastic(): + es = AsyncElasticsearch(settings.elasticsearch_host) + yield es + await es.close() diff --git a/async_api/tests/test_example/test_hello.py b/async_api/tests/test_example/test_hello.py deleted file mode 100644 index 6d36275..0000000 --- a/async_api/tests/test_example/test_hello.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Tests for hello function.""" - -import pytest - -from example import hello - - -@pytest.mark.parametrize( - ("name", "expected"), - [ - ("Jeanette", "Hello Jeanette!"), - ("Raven", "Hello Raven!"), - ("Maxine", "Hello Maxine!"), - ("Matteo", "Hello Matteo!"), - ("Destinee", "Hello Destinee!"), - ("Alden", "Hello Alden!"), - ("Mariah", "Hello Mariah!"), - ("Anika", "Hello Anika!"), - ("Isabella", "Hello Isabella!"), - ], -) -def test_hello(name, expected): - """Example test with parametrization.""" - assert hello(name) == expected diff --git a/async_api/tests/test_example/__init__.py b/async_api/tests/test_services/__init__.py similarity index 100% rename from async_api/tests/test_example/__init__.py rename to async_api/tests/test_services/__init__.py diff --git a/async_api/tests/test_services/test_genre_service.py b/async_api/tests/test_services/test_genre_service.py new file mode 100644 index 0000000..4713228 --- /dev/null +++ b/async_api/tests/test_services/test_genre_service.py @@ -0,0 +1,37 @@ +from uuid import UUID + +import pytest + +from models.genre import Genre +from services.genre import GenreService + + +@pytest.fixture() +async def genre_service(elastic): + return GenreService(elastic) + + +async def test_get_genre_by_id(genre_service): + # Arrange + action_genre_id = UUID("3d8d9bf5-0d90-4353-88ba-4ccc5d2c07ff") + + # Act + genre = await genre_service.get_by_id(action_genre_id) + + # Assert + assert genre is not None + assert genre.id == action_genre_id + assert genre.name == "Action" + + +async def test_search_genres(genre_service): + # Arrange + page = 2 + size = 5 + + # Act + genres = await genre_service.search(page, size) + + # Assert + assert len(genres) == size + assert all(isinstance(genre, Genre) for genre in genres) diff --git a/async_api/tests/test_services/test_person_service.py b/async_api/tests/test_services/test_person_service.py new file mode 100644 index 0000000..b218f8b --- /dev/null +++ b/async_api/tests/test_services/test_person_service.py @@ -0,0 +1,57 @@ +from uuid import UUID + +import pytest + +from models.person import Person +from services.person import PersonService + + +@pytest.fixture() +async def person_service(elastic): + return PersonService(elastic) + + +async def test_get_person_by_id(person_service: PersonService): + # Arrange + george_lucas_id = UUID("a5a8f573-3cee-4ccc-8a2b-91cb9f55250a") + + # Act + person = await person_service.get_by_id(george_lucas_id) + + # Assert + assert person is not None + assert person.id == george_lucas_id + assert person.full_name == "George Lucas" + assert len(person.films) == 46 + assert person.films[0].id == UUID("516f91da-bd70-4351-ba6d-25e16b7713b7") + assert person.films[0].roles == ["director", "writer"] + + +async def test_search_persons(person_service: PersonService): + # Arrange + page = 2 + size = 5 + + # Act + persons = await person_service.search(page=page, size=size) + + # Assert + assert len(persons) == size + assert all(isinstance(person, Person) for person in persons) + + +async def test_search_persons_with_query(person_service: PersonService): + # Arrange + query = "George Lucas" + george_lucas_id = UUID("a5a8f573-3cee-4ccc-8a2b-91cb9f55250a") + + # Act + persons = await person_service.search(query=query) + + # Assert + person = persons[0] + assert person.id == george_lucas_id + assert person.full_name == "George Lucas" + assert len(person.films) == 46 + assert person.films[0].id == UUID("516f91da-bd70-4351-ba6d-25e16b7713b7") + assert person.films[0].roles == ["director", "writer"]