diff --git a/docs/source/index.rst b/docs/source/index.rst index 45ff020..985d88c 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -5,25 +5,120 @@ GraphQL API Gateway Зависимости =========== +Install the appropriate software: +1. [Docker Desktop](https://www.docker.com). +2. [Git](https://github.com/git-guides/install-git). +3. [PyCharm](https://www.jetbrains.com/ru-ru/pycharm/download) (optional). Установка ========= +Clone the repository to your computer: +.. code-block::shell + git clone https://github.com/mnv/python-course-graphql-gateway +1. To configure the application copy `.env.sample` into `.env` file: + + .. code-block::shell + cp .env.sample .env + This file contains environment variables that will share their values across the application. + The sample file (`.env.sample`) contains a set of variables with default values. + So it can be configured depending on the environment. + +2. Build the container using Docker Compose: + + .. code-block::shell + docker compose build + This command should be run from the root directory where `Dockerfile` is located. + You also need to build the docker container again in case if you have updated `requirements.txt`. + +3. To run the project inside the Docker container: + + .. code-block::shell + docker compose up + When containers are up server starts at [http://127.0.0.1:8000/graphql](http://127.0.0.1:8000/graphql). You can open it in your browser. Использование ============= - +Запросы +_______ +This project provides fixtures to test GraphQL. Fixtures are located in src/fixtures. There are JSON-files for favorite places and countries information. GraphQL application uses these fixtures to emulate REST API responses. + +Query example to request a list of favorite places: + +query { + places { + latitude + longitude + description + city + locality + } +} +Query example to request a list of favorite places with countries information: + +query { + places { + latitude + longitude + description + city + locality + country { + name + capital + alpha2code + alpha3code + capital + region + subregion + population + latitude + longitude + demonym + area + numericCode + flag + currencies + languages + } + } +} +This query will request additional information about related countries in optimal way using data loaders to prevent N + 1 requests problem. Автоматизация ============= +The project contains a special `Makefile` that provides shortcuts for a set of commands: + +1. Build the Docker container: + + .. code-block::shell + make build +2. Generate Sphinx documentation run: + + .. code-block::shell + make docs-html +3. Autoformat source code: + + .. code-block::shell + make format +4. Static analysis (linters): + + .. code-block::shell + make lint +5. Autotests: + .. code-block::shell + make test + The test coverage report will be located at `src/htmlcov/index.html`. + So you can estimate the quality of automated test coverage. +6. Run autoformat, linters and tests in one command: -Тестирование -============ + .. code-block::shell + make all diff --git a/src/context.py b/src/context.py index e59ee03..ddf09f1 100644 --- a/src/context.py +++ b/src/context.py @@ -2,10 +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 +14,7 @@ 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..a8494d1 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,21 @@ 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() + + 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..5cc4dbd --- /dev/null +++ b/src/fixtures/news/ie.json @@ -0,0 +1,33 @@ +{ + "status": "ok", + "totalResults": 6036, + "articles": + [ + { + "source": + { + "id": "bbc-news", + "name": "BBC News" + }, + "author": null, + "title": "Ireland beat Wales to make it three wins in three", + "description": "Ireland move closer to completing back-to-back Six Nations Grand Slams with an unconvincing win over a spirited Wales side in Dublin.", + "url": "https://www.bbc.co.uk/sport/rugby-union/68391672", + "publishedAt": "2024-02-24T16:16:36Z", + "content": "Ciaran Frawley marked his first Six Nations start with an important second-half try\r\n\r\n
Six Nations 2024: Ireland v Wales
Ireland: (17) 31
Tries: Sheeh\u2026 [+2634 chars]" + }, + { + "source": + { + "id": "business-insider", + "name": "Business Insider" + }, + "author": "Alexis McSparren", + "title": "I moved from the US to Ireland. Here are 11 things that surprised me most.", + "description": "I spontaneously moved from the US to Ireland, and I wasn't prepared for the differences in the language, the pace of life, or the function of alcohol.", + "url": "https://www.businessinsider.com/surprising-things-moving-to-ireland-from-us-2022-2", + "publishedAt": "2024-02-21T15:17:30Z", + "content": "I've been blown away by the beauty of the Irish countryside.Alexis McSparren\r\n
    \n
  • After growing up in the US, I made the spontaneous decision to move to Dublin, Ireland.
  • \n
  • I've experience\u2026 [+10021 chars]" + } + ] +} \ 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..4e3bfcf --- /dev/null +++ b/src/fixtures/news/ru.json @@ -0,0 +1,33 @@ +{ + "status": "ok", + "totalResults": 8746, + "articles": + [ + { + "source": + { + "id": "post-news", + "name": "POST News" + }, + "author": "https://www.facebook.com/postnews", + "title": "Ticks began to wake up all over Russia. The season of their activity will come in two weeks", + "description": "Precautions should be taken now — with the onset of warm weather, the ticks will finally wake up and switch to nutrition (attacking animals and people).", + "url": "https://www.postnews.ru/news/live/world-europe-68431017", + "publishedAt": "2024-03-29T08:03:55Z", + "content": "The first cases of bites have already been recorded. The number of visits to doctors is growing rapidly in St. Petersburg, Sverdlovsk, Pskov regions and in 19 other regions of the country.\u2026 [+569 chars]" + }, + { + "source": + { + "id": null, + "name": "NPR" + }, + "author": null, + "title": "Alexei Navalny has died in prison at 47, Moscow says", + "description": "The Federal Prison Service said in a statement that Navalny felt unwell after a walk on Friday and lost consciousness. The politician's team says it has received no confirmation of his death so far.", + "url": "https://www.npr.org/2024/02/16/1231946376/alexei-navalny-death-in-prison-vladimir-putin-opposition", + "publishedAt": "2024-02-16T12:18:22Z", + "content": "Alexei Navalny is seen in 2012 behind the bars in a police van after he was detained during protests in Moscow a day after Putin's inauguration.\r\nSergey Ponomarev/AP\r\nMOSCOW Alexei Navalny, the fierc\u2026 [+1635 chars]" + } + ] +} \ No newline at end of file diff --git a/src/models/news.py b/src/models/news.py new file mode 100644 index 0000000..a1f7d54 --- /dev/null +++ b/src/models/news.py @@ -0,0 +1,17 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + + +class NewsModel(BaseModel): + """ + Модель для описания новостей. + """ + + author: str | None = Field(title="Автор") + source: str = Field(title="Источник") + title: str = Field(title="Заголовок") + description: str | None = Field(title="Описание") + url: str = Field(title="Ссылка") + published_at: datetime = Field(title="Дата публикации") + content: str | None = Field(title="Содержание") \ No newline at end of file diff --git a/src/schema.py b/src/schema.py index 7e7c167..e653740 100644 --- a/src/schema.py +++ b/src/schema.py @@ -5,11 +5,25 @@ 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() + source = graphene.String() + title = graphene.String() + description = graphene.String() + url = graphene.String() + published_at = graphene.DateTime() + content = graphene.String() + + class Country(graphene.ObjectType): """ Тип объекта страны. @@ -43,6 +57,7 @@ class Place(graphene.ObjectType): 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 +76,23 @@ 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 +100,7 @@ class Query(graphene.ObjectType): """ places = graphene.List(Place) + place = graphene.Field(Place, place_id=graphene.Int()) @staticmethod def resolve_places( @@ -75,5 +108,10 @@ 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 + ) -> list[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..1a3aca1 --- /dev/null +++ b/src/services/news.py @@ -0,0 +1,39 @@ +import json +import os + +from models.news import NewsModel + + +class NewsService: + """ + Сервис для работы с данными о новостях. + """ + + @staticmethod + def get_news() -> dict[str, list[NewsModel]]: + """ + Получение списка новостей. + :return: + """ + news_dir = "fixtures/news/" + base_dir = os.path.dirname(os.path.dirname(__file__)) + news_dir = os.path.join(base_dir, news_dir) + files = os.listdir(news_dir) + result = {} + for news_file in files: + alpha2code = news_file.split(".")[0] + with open(news_dir + news_file, encoding="utf-8") as file: + if data := json.load(file): + result[alpha2code] = [ + NewsModel( + author=article.get("author"), + source=article.get("source").get("name"), + title=article.get("title"), + description=article.get("description"), + url=article.get("url"), + published_at=article.get("publishedAt"), + content=article.get("content"), + ) + for article 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..a2d772b 100644 --- a/src/services/places.py +++ b/src/services/places.py @@ -34,3 +34,12 @@ def get_places(self) -> list[PlaceModel]: ] return result + + def get_place(self, place_id: int) -> PlaceModel: + """ + Получение любимого места. + :param place_id: Идентификатор. + :return: + """ + result = {place.id: place for place in self.get_places()} + return result.get(place_id, None) \ No newline at end of file