diff --git a/docs/source/index.rst b/docs/source/index.rst index 45ff020..93c5733 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -6,24 +6,197 @@ GraphQL API Gateway Зависимости =========== +Установите требуемое ПО: + +1. Docker для контейнеризации – |link_docker| + +.. |link_docker| raw:: html + + Docker Desktop + +2. Для работы с системой контроля версий – |link_git| + +.. |link_git| raw:: html + + Git + +3. IDE для работы с исходным кодом – |link_pycharm| + +.. |link_pycharm| raw:: html + + PyCharm Установка ========= +1. Клонируйте репозиторий проекта в свою рабочую директорию: + + .. code-block:: console + + git clone https://github.com/MNV/python-course-graphql-gateway-example.git + +Перед началом использования приложения необходимо его сконфигурировать. + +.. note:: + + Для конфигурации выполните команды, описанные ниже, находясь в корневой директории проекта (на уровне с директорией `src`). + +2. Скопируйте файл настроек `.env.sample`, создав файл `.env`: + .. code-block:: console + + cp .env.sample .env + + Этот файл содержит преднастроенные переменные окружения, значения которых будут общими для всего приложения. + Файл примера (`.env.sample`) содержит набор переменных со значениями по умолчанию. + Созданный файл `.env` можно настроить в зависимости от окружения. + + .. warning:: + + Никогда не добавляйте в систему контроля версий заполненный файл `.env` для предотвращения компрометации информации о конфигурации приложения. + +3. Соберите Docker-контейнер с помощью Docker Compose: + .. code-block:: console + + docker compose build + + Данную команду необходимо выполнять повторно в случае обновления зависимостей в файле `requirements.txt`. + +4. После сборки контейнеров можно их запустить командой: + .. code-block:: console + + docker compose up + + Данная команда запустит собранные контейнеры для приложения и базы данных. + Когда запуск завершится, сервер начнет работать по адресу `http://127.0.0.1:8000`. + Использование ============= - +Запросы +------- +Этот проект предоставляет фикстуры для тестирования GraphQL. Фикстуры расположены в `src/fixtures`. +В них содержатся JSON-файлы для информации о любимых местах и странах. +GraphQL-приложение использует эти фикстуры для эмуляции ответов REST API. + +Пример запроса на получение списка любимых мест: + +.. code-block:: graphql + + query { + places { + latitude + longitude + description + city + locality + } + } + +Пример запроса на получение списка любимых мест с информацией о странах: + +.. code-block:: graphql + + query { + places { + latitude + longitude + description + city + locality + country { + name + capital + alpha2code + alpha3code + capital + region + subregion + population + latitude + longitude + demonym + area + numericCode + flag + currencies + languages + } + } + } + +Этот запрос запросит дополнительную информацию о связанных странах оптимальным способом, используя загрузчики данных, чтобы предотвратить проблему N + 1 запросов. Автоматизация ============= +Проект содержит специальный файл (`Makefile`) для автоматизации выполнения команд: + +1. Сборка Docker-контейнера. +2. Генерация документации. +3. Запуск форматирования кода. +4. Запуск статического анализа кода (выявление ошибок типов и форматирования кода). +5. Запуск автоматических тестов. +6. Запуск всех функций поддержки качества кода (форматирование, линтеры, автотесты). + +Инструкция по запуску этих команд находится в файле `README.md`. Тестирование ============ +Для запуска автоматических тестов выполните команду: + +.. code-block:: console + + make test + +Отчет о тестировании находится в файле `src/htmlcov/index.html`. + + +Документация к исходному коду +============================= + +Сервисный слой +============== + +Сервис для стран +---------------- +.. automodule:: services.countries + :members: + +Сервис для новостей +------------------- +.. automodule:: services.news + :members: + +Сервис для мест +--------------- +.. automodule:: services.places + :members: + +Модели +====== + +Миксин +------ +.. automodule:: models.mixins + :members: + +Модель для стран +---------------- +.. automodule:: models.countries + :members: + +Модель для новостей +------------------- +.. automodule:: models.news + :members: + +Модель для мест +--------------- +.. automodule:: models.places + :members: \ No newline at end of file diff --git a/src/context.py b/src/context.py index e59ee03..8c6e345 100644 --- a/src/context.py +++ b/src/context.py @@ -2,9 +2,10 @@ from promise.dataloader import DataLoader -from dataloaders import CountryLoader +from dataloaders import CountryLoader, NewsLoader DATA_LOADER_COUNTRIES = "countries" +DATA_LOADER_NEWS = "news" def register_dataloaders() -> Dict[str, DataLoader]: @@ -14,7 +15,10 @@ def register_dataloaders() -> Dict[str, DataLoader]: :return: """ - return {DATA_LOADER_COUNTRIES: CountryLoader()} + return { + DATA_LOADER_COUNTRIES: CountryLoader(), + DATA_LOADER_NEWS: NewsLoader(), + } def get_context() -> Dict[str, Dict[str, DataLoader]]: diff --git a/src/dataloaders.py b/src/dataloaders.py index 7ea474e..7c3139f 100644 --- a/src/dataloaders.py +++ b/src/dataloaders.py @@ -2,6 +2,7 @@ from promise.dataloader import DataLoader from services.countries import CountriesService +from services.news import NewsService class CountryLoader(DataLoader): @@ -24,3 +25,23 @@ def batch_load_fn( # pylint: disable=method-hidden # формирование результата с сохранением порядка из alpha2codes return Promise.resolve([countries_map.get(code) for code in alpha2codes]) + + +class NewsLoader(DataLoader): + """ + Загрузчик данных о новостях. + """ + + def batch_load_fn( # pylint: disable=method-hidden + self, alpha2codes: list[str] + ) -> Promise: + """ + Функция для загрузки связанных данных по переданному множеству значений. + :param alpha2codes: Список ISO Alpha2-кодов стран + :return: + """ + + news = NewsService().get_news() + + # формирование результата с сохранением порядка из alpha2codes + return Promise.resolve([news.get(code.lower()) for code in alpha2codes if code]) \ No newline at end of file diff --git a/src/fixtures/news/ie.json b/src/fixtures/news/ie.json new file mode 100644 index 0000000..506117b --- /dev/null +++ b/src/fixtures/news/ie.json @@ -0,0 +1,45 @@ +{ + "status": "ok", + "totalResults": 38, + "articles": [ + { + "source": { + "id": "google-news", + "name": "Google News" + }, + "author": "RTE.ie", + "title": "Sunak tells Stormont leaders 'real work starts now' - RTE.ie", + "description": null, + "url": "https://news.google.com/rss/articles/CBMiM2h0dHBzOi8vd3d3LnJ0ZS5pZS9uZXdzLzIwMjQvMDIwNS8xNDMwNTM4LXN0b3Jtb250L9IBH2h0dHBzOi8vYW1wLnJ0ZS5pZS9hbXAvMTQzMDUzOC8?oc=5", + "urlToImage": null, + "publishedAt": "2024-02-05T14:17:58Z", + "content": null + }, + { + "source": { + "id": "google-news", + "name": "Google News" + }, + "author": "The Guardian", + "title": "Cern aims to build \u20ac20bn atom-smasher to unlock secrets of universe - The Guardian", + "description": null, + "url": "https://news.google.com/rss/articles/CBMib2h0dHBzOi8vd3d3LnRoZWd1YXJkaWFuLmNvbS9zY2llbmNlLzIwMjQvZmViLzA1L2Nlcm4tYXRvbS1zbWFzaGVyLXVubG9jay1zZWNyZXRzLXVuaXZlcnNlLWxhcmdlLWhhZHJvbi1jb2xsaWRlctIBb2h0dHBzOi8vYW1wLnRoZWd1YXJkaWFuLmNvbS9zY2llbmNlLzIwMjQvZmViLzA1L2Nlcm4tYXRvbS1zbWFzaGVyLXVubG9jay1zZWNyZXRzLXVuaXZlcnNlLWxhcmdlLWhhZHJvbi1jb2xsaWRlcg?oc=5", + "urlToImage": null, + "publishedAt": "2024-02-05T13:42:00Z", + "content": null + }, + { + "source": { + "id": "google-news", + "name": "Google News" + }, + "author": "RTE.ie", + "title": "Dad's Army star Ian Lavender has died at the age of 77 - RTE.ie", + "description": null, + "url": "https://news.google.com/rss/articles/CBMiaWh0dHBzOi8vd3d3LnJ0ZS5pZS9lbnRlcnRhaW5tZW50LzIwMjQvMDIwNS8xNDMwNTY2LWRhZHMtYXJteS1zdGFyLWlhbi1sYXZlbmRlci1oYXMtZGllZC1hdC10aGUtYWdlLW9mLTc3L9IBAA?oc=5", + "urlToImage": null, + "publishedAt": "2024-02-05T13:18:45Z", + "content": null + } + ] +} \ No newline at end of file diff --git a/src/fixtures/news/rs.json b/src/fixtures/news/rs.json new file mode 100644 index 0000000..e60554e --- /dev/null +++ b/src/fixtures/news/rs.json @@ -0,0 +1,45 @@ +{ + "status": "ok", + "totalResults": 34, + "articles": [ + { + "source": { + "id": "google-news", + "name": "Google News" + }, + "author": "Espreso", + "title": "AKO IMATE OVE PROMENE NA KO\u017dI ODMAH SE JAVITE LEKARU: Obi\u010dno se javljaju kod ljudi STARIJIH OD 50 GODINA - Espreso", + "description": null, + "url": "https://news.google.com/rss/articles/CBMiaWh0dHBzOi8vd3d3LmVzcHJlc28uY28ucnMvbGlmZXN0eWxlL3pkcmF2bGplLzE0MjU1MzAvYWtvLWltYXRlLW92ZS1wcm9tZW5lLW5hLWtvemktb2RtYWgtc2UtamF2aXRlLWxla2FyddIBWmh0dHBzOi8vd3d3LmVzcHJlc28uY28ucnMvYW1wLzE0MjU1MzAvYWtvLWltYXRlLW92ZS1wcm9tZW5lLW5hLWtvemktb2RtYWgtc2UtamF2aXRlLWxla2FydQ?oc=5", + "urlToImage": null, + "publishedAt": "2024-02-05T14:22:12Z", + "content": null + }, + { + "source": { + "id": "google-news", + "name": "Google News" + }, + "author": "Benchmark", + "title": "Apple savitljivi telefon mo\u017eda vidimo izme\u0111u 2026. i 2027. godine - Benchmark", + "description": null, + "url": "https://news.google.com/rss/articles/CBMiY2h0dHBzOi8vYmVuY2htYXJrLnJzL3Zlc3RpL3VyZWRhamkvYXBwbGUtc2F2aXRsaml2aS10ZWxlZm9uLW1vemRhLXZpZGltby1pem1lZHUtMjAyNi1pLTIwMjctZ29kaW5lL9IBAA?oc=5", + "urlToImage": null, + "publishedAt": "2024-02-05T14:11:45Z", + "content": null + }, + { + "source": { + "id": "google-news", + "name": "Google News" + }, + "author": "Blic.rs", + "title": "\"BLIC\" SAZNAJE: HBO \u0107e Bikovi\u0107u isplatiti ceo honorar za \"White Lotus\", ODU\u0160EVI\u0106E VAS kada budete saznali kome glumac daje ceo iznos - Blic.rs", + "description": null, + "url": "https://news.google.com/rss/articles/CBMidmh0dHBzOi8vd3d3LmJsaWMucnMvemFiYXZhL2hiby1jZS1iaWtvdmljdS1pc3BsYXRpdGktY2VvLWhvbm9yYXItemEtd2hpdGUtbG90dXMtZXZvLWtvbWUtZ2x1bWFjLWRhamUtY2Vsb2t1cGFuL2NnNjl3ejnSAQA?oc=5", + "urlToImage": null, + "publishedAt": "2024-02-05T14:08:44Z", + "content": null + } + ] +} \ No newline at end of file diff --git a/src/fixtures/news/ru.json b/src/fixtures/news/ru.json new file mode 100644 index 0000000..947804f --- /dev/null +++ b/src/fixtures/news/ru.json @@ -0,0 +1,45 @@ +{ + "status": "ok", + "totalResults": 34, + "articles": [ + { + "source": { + "id": "google-news", + "name": "Google News" + }, + "author": "\u0424\u043e\u043d\u0442\u0430\u043d\u043a\u0430.\u0420\u0443", + "title": "\u0417\u0430\u0432\u043e\u0434 \u00ab\u0410\u0432\u0442\u043e\u0442\u043e\u0440\u00bb \u0432 \u041a\u0430\u043b\u0438\u043d\u0438\u043d\u0433\u0440\u0430\u0434\u0435 \u043e\u0431\u044a\u044f\u0432\u0438\u043b \u043e \u0437\u0430\u043f\u0443\u0441\u043a\u0435 \u0441\u043e\u0431\u0441\u0442\u0432\u0435\u043d\u043d\u043e\u0433\u043e \u0431\u0440\u0435\u043d\u0434\u0430 \u0430\u0432\u0442\u043e\u043c\u043e\u0431\u0438\u043b\u0435\u0439 - 5 \u0444\u0435\u0432\u0440\u0430\u043b\u044f 2024 - \u0424\u043e\u043d\u0442\u0430\u043d\u043a\u0430.\u0420\u0443", + "description": null, + "url": "https://news.google.com/rss/articles/CBMiLGh0dHBzOi8vd3d3LmZvbnRhbmthLnJ1LzIwMjQvMDIvMDUvNzMxOTc0NDMv0gEA?oc=5", + "urlToImage": null, + "publishedAt": "2024-02-05T13:21:21Z", + "content": null + }, + { + "source": { + "id": "google-news", + "name": "Google News" + }, + "author": "Meduza", + "title": "Politico: 13-\u0439 \u043f\u0430\u043a\u0435\u0442 \u0441\u0430\u043d\u043a\u0446\u0438\u0439 \u0415\u0421 \u043f\u0440\u043e\u0442\u0438\u0432 \u0420\u043e\u0441\u0441\u0438\u0438 \u0431\u0443\u0434\u0435\u0442 \u043d\u043e\u0441\u0438\u0442\u044c \u00ab\u0441\u0438\u043c\u0432\u043e\u043b\u0438\u0447\u0435\u0441\u043a\u0438\u0439\u00bb \u0445\u0430\u0440\u0430\u043a\u0442\u0435\u0440 \u2014 Meduza - Meduza", + "description": null, + "url": "https://news.google.com/rss/articles/CBMic2h0dHBzOi8vbWVkdXphLmlvL25ld3MvMjAyNC8wMi8wNS9wb2xpdGljby0xMy15LXBha2V0LXNhbmt0c2l5LWVzLXByb3Rpdi1yb3NzaWktYnVkZXQtbm9zaXQtc2ltdm9saWNoZXNraXktaGFyYWt0ZXLSAQA?oc=5", + "urlToImage": null, + "publishedAt": "2024-02-05T12:26:00Z", + "content": null + }, + { + "source": { + "id": "google-news", + "name": "Google News" + }, + "author": "\u0420\u0411\u041a-\u0423\u043a\u0440\u0430\u0438\u043d\u0430", + "title": "\u041d\u0435\u043c\u0435\u0446\u043a\u0438\u0439 \u0433\u0435\u043d\u0435\u0440\u0430\u043b \u0443\u0432\u0435\u0440\u0435\u043d \u0432 \u043f\u043e\u0431\u0435\u0434\u0435 \u0423\u043a\u0440\u0430\u0438\u043d\u044b \u0432 \u0432\u043e\u0439\u043d\u0435 \u0441 \u0420\u043e\u0441\u0441\u0438\u0435\u0439: \u0447\u0442\u043e \u0434\u043b\u044f \u044d\u0442\u043e\u0433\u043e \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e - \u0420\u0411\u041a-\u0423\u043a\u0440\u0430\u0438\u043d\u0430", + "description": null, + "url": "https://news.google.com/rss/articles/CBMiWWh0dHBzOi8vd3d3LnJiYy51YS91a3IvbmV3cy9uaW1ldHNraXktZ2VuZXJhbC11cGV2bmVuaXktcGVyZW1vemktdWtyYXlpbmktMTcwNzEzNTQ2OC5odG1s0gFdaHR0cHM6Ly93d3cucmJjLnVhL3Vrci9uZXdzL25pbWV0c2tpeS1nZW5lcmFsLXVwZXZuZW5peS1wZXJlbW96aS11a3JheWluaS0xNzA3MTM1NDY4Lmh0bWwvYW1w?oc=5", + "urlToImage": null, + "publishedAt": "2024-02-05T12:17:48Z", + "content": null + } + ] +} \ No newline at end of file diff --git a/src/models/news.py b/src/models/news.py new file mode 100644 index 0000000..733778f --- /dev/null +++ b/src/models/news.py @@ -0,0 +1,16 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field + + +class NewsModel(BaseModel): + """ + Модель данных о новостях. + """ + + author: str = Field(title="Автор") + title: str = Field(title="Название") + description: Optional[str] = Field(title="Описание") + url: str = Field(title="Ссылка") + published_at: Optional[datetime] = Field(title="Опубликовано") \ No newline at end of file diff --git a/src/schema.py b/src/schema.py index 7e7c167..2a369f4 100644 --- a/src/schema.py +++ b/src/schema.py @@ -5,11 +5,23 @@ from graphql import ResolveInfo from promise import Promise -from context import DATA_LOADER_COUNTRIES +from context import DATA_LOADER_COUNTRIES, DATA_LOADER_NEWS from models.places import PlaceModel from services.places import PlacesService +class News(graphene.ObjectType): + """ + Тип объекта новости. + """ + + author = graphene.String() + title = graphene.String() + description = graphene.String() + url = graphene.String() + published_at = graphene.DateTime() + + class Country(graphene.ObjectType): """ Тип объекта страны. @@ -37,12 +49,14 @@ class Place(graphene.ObjectType): Тип объекта любимого места. """ + id = graphene.Int() latitude = graphene.Float() longitude = graphene.Float() description = graphene.String() city = graphene.String() locality = graphene.String() country = graphene.Field(Country) + news = graphene.List(News) @staticmethod def resolve_country(parent: PlaceModel, info: ResolveInfo) -> Promise: @@ -61,6 +75,22 @@ def resolve_country(parent: PlaceModel, info: ResolveInfo) -> Promise: return Promise.resolve([]) + @staticmethod + def resolve_news(parent: PlaceModel, info: ResolveInfo) -> Promise: + """ + Получение связанной информации о новостях для объектов любимых мест. + :param parent: Объект любимого места. + :param info: Объект с метаинформацией и данных о контексте запроса. + :return: + """ + + if info.context: + dataloaders = info.context["dataloaders"] + + return dataloaders[DATA_LOADER_NEWS].load(str(parent.country)) + + return Promise.resolve([]) + class Query(graphene.ObjectType): """ @@ -68,6 +98,7 @@ class Query(graphene.ObjectType): """ places = graphene.List(Place) + place = graphene.Field(Place, place_id=graphene.Int()) @staticmethod def resolve_places( @@ -75,5 +106,11 @@ def resolve_places( ) -> list[PlaceModel]: return PlacesService().get_places() + @staticmethod + def resolve_place( + parent: Optional[dict], info: ResolveInfo, place_id: int, # pylint: disable=unused-argument + ) -> PlaceModel: + return PlacesService().get_place(place_id) + schema = Schema(query=Query) diff --git a/src/services/news.py b/src/services/news.py new file mode 100644 index 0000000..d0fb85d --- /dev/null +++ b/src/services/news.py @@ -0,0 +1,36 @@ +import json +import os +from glob import glob + +from models.news import NewsModel + + +class NewsService: + """ + Сервис для работы с новостями. + """ + + def get_news(self) -> dict[str, list[NewsModel]]: + """ + Получение списка новостей + :return: + """ + + result = {} + for filename in glob("fixtures/news/*"): + with open(filename, encoding="utf-8") as file: + # Получить код из названия файла + alpha2code = os.path.basename(filename).split(".")[0] + if data := json.load(file): + result[alpha2code] = [ + NewsModel( + author=news.get("author"), + title=news.get("title"), + description=news.get("description"), + url=news.get("url"), + published_at=news.get("published_at"), + ) + for news in data.get("articles", []) + ] + + return result \ No newline at end of file diff --git a/src/services/places.py b/src/services/places.py index ad9672b..d34adff 100644 --- a/src/services/places.py +++ b/src/services/places.py @@ -1,5 +1,5 @@ import json - +from typing import Optional from models.places import PlaceModel @@ -34,3 +34,7 @@ def get_places(self) -> list[PlaceModel]: ] return result + + def get_place(self, place_id: int) -> Optional[PlaceModel]: + result = [place for place in self.get_places() if place.id == place_id] + return result[0] if len(result) > 0 else None \ No newline at end of file diff --git a/src/tests/functional/test_context.py b/src/tests/functional/test_context.py new file mode 100644 index 0000000..0b8d7be --- /dev/null +++ b/src/tests/functional/test_context.py @@ -0,0 +1,33 @@ +from context import get_context, register_dataloaders +from dataloaders import CountryLoader, NewsLoader + + +def test_get_context(): + """ + Тестирование контекста + """ + context = get_context() + + assert len(context) == 1 + assert context.keys() == {"dataloaders"} + assert isinstance(context["dataloaders"], dict) + assert len(context["dataloaders"]) == 2 + + dataloaders = context["dataloaders"] + assert dataloaders.keys() == {"countries", "news"} + assert isinstance(dataloaders["countries"], CountryLoader) + assert isinstance(dataloaders["news"], NewsLoader) + + +def test_register_dataloaders(): + """ + Тестирование содержимого контекста + """ + dataloaders = register_dataloaders() + + assert len(dataloaders) == 2 + assert isinstance(dataloaders, dict) + assert dataloaders.keys() == {"countries", "news"} + + assert isinstance(dataloaders["countries"], CountryLoader) + assert isinstance(dataloaders["news"], NewsLoader) \ No newline at end of file diff --git a/src/tests/functional/test_graphql.py b/src/tests/functional/test_graphql.py new file mode 100644 index 0000000..33061af --- /dev/null +++ b/src/tests/functional/test_graphql.py @@ -0,0 +1,132 @@ +import pytest +from graphene.test import Client + +from context import get_context +from schema import schema + + +@pytest.fixture +def context_fixture(): + yield get_context() + + +def test_get_places(context_fixture): + query = """ + { + places { + latitude + longitude + description + city + locality + } + } + """ + client = Client(schema, context_value=context_fixture) + executed = client.execute(query) + assert isinstance(executed, dict) + + places = executed["data"]["places"] + assert len(places) == 6 + + place = places[0] + assert place["latitude"] == 58.0081 + assert place["longitude"] == 56.249 + assert place["description"] == "Супер место!" + assert place["city"] == "Perm" + assert place["locality"] == "Sverdlovsky City District" + + +def test_places_with_country_and_news(context_fixture): + query = """ + { + places { + description + city + country { + name + alpha2code + capital + } + news { + title + } + } + } + """ + client = Client(schema, context_value=context_fixture) + executed = client.execute(query) + assert isinstance(executed, dict) + + places = executed["data"]["places"] + assert len(places) == 6 + + place = places[0] + # проверка, что не пришло то, что не заказывали + assert place.get("latitude", None) is None + assert place.get("longitude", None) is None + assert place["description"] == "Супер место!" + assert place["city"] == "Perm" + + country = place["country"] + assert country["name"] == "Russian Federation" + assert country["capital"] == "Moscow" + assert country["alpha2code"] == "RU" + + +def test_place_with_country_and_news(context_fixture): + query = """ + { + place(placeId: 57) { + description + city + locality + country { + name + capital + alpha2code + } + news { + author + title + description + url + publishedAt + } + } + } + """ + + client = Client(schema, context_value=context_fixture) + executed = client.execute(query) + assert isinstance(executed, dict) + + place = executed["data"]["place"] + assert len(place) == 5 + + assert place["description"] == "Графтон-стрит." + assert place["city"] == "Dublin" + assert place["locality"] == "Portobello" + # проверка, что не пришло то, что не заказывали + assert place.get("latitude", None) is None + assert place.get("longitude", None) is None + + country = place["country"] + assert country["name"] == "Ireland" + assert country["capital"] == "Dublin" + assert country["alpha2code"] == "IE" + + news = place["news"] + assert len(news) == 3 + assert ( + news[0]["title"] + == "Sunak tells Stormont leaders 'real work starts now' - RTE.ie" + ) + assert ( + news[1]["title"] + == "Cern aims to build €20bn atom-smasher to unlock secrets of universe - The Guardian" + ) + assert ( + news[2]["title"] + == "Dad's Army star Ian Lavender has died at the age of 77 - RTE.ie" + ) \ No newline at end of file