diff --git a/.gitchangelog.rc b/.gitchangelog.rc new file mode 100644 index 0000000..9ccdbea --- /dev/null +++ b/.gitchangelog.rc @@ -0,0 +1,289 @@ +# -*- coding: utf-8; mode: python -*- +## +## Format +## +## ACTION: [AUDIENCE:] COMMIT_MSG [!TAG ...] +## +## Description +## +## ACTION is one of 'chg', 'fix', 'new' +## +## Is WHAT the change is about. +## +## 'chg' is for refactor, small improvement, cosmetic changes... +## 'fix' is for bug fixes +## 'new' is for new features, big improvement +## +## AUDIENCE is optional and one of 'dev', 'usr', 'pkg', 'test', 'doc' +## +## Is WHO is concerned by the change. +## +## 'dev' is for developpers (API changes, refactors...) +## 'usr' is for final users (UI changes) +## 'pkg' is for packagers (packaging changes) +## 'test' is for testers (test only related changes) +## 'doc' is for doc guys (doc only changes) +## +## COMMIT_MSG is ... well ... the commit message itself. +## +## TAGs are additionnal adjective as 'refactor' 'minor' 'cosmetic' +## +## They are preceded with a '!' or a '@' (prefer the former, as the +## latter is wrongly interpreted in github.) Commonly used tags are: +## +## 'refactor' is obviously for refactoring code only +## 'minor' is for a very meaningless change (a typo, adding a comment) +## 'cosmetic' is for cosmetic driven change (re-indentation, 80-col...) +## 'wip' is for partial functionality but complete subfunctionality. +## +## Example: +## +## new: usr: support of bazaar implemented +## chg: re-indentend some lines !cosmetic +## new: dev: updated code to be compatible with last version of killer lib. +## fix: pkg: updated year of licence coverage. +## new: test: added a bunch of test around user usability of feature X. +## fix: typo in spelling my name in comment. !minor +## +## Please note that multi-line commit message are supported, and only the +## first line will be considered as the "summary" of the commit message. So +## tags, and other rules only applies to the summary. The body of the commit +## message will be displayed in the changelog without reformatting. + + +## +## ``ignore_regexps`` is a line of regexps +## +## Any commit having its full commit message matching any regexp listed here +## will be ignored and won't be reported in the changelog. +## +ignore_regexps = [ + r'@minor', r'!minor', + r'@cosmetic', r'!cosmetic', + r'@refactor', r'!refactor', + r'@wip', r'!wip', + r'^([cC]hg|[fF]ix|[nN]ew)\s*:\s*[p|P]kg:', + r'^([cC]hg|[fF]ix|[nN]ew)\s*:\s*[d|D]ev:', + r'^(.{3,3}\s*:)?\s*[fF]irst commit.?\s*$', + r'^$', ## ignore commits with empty messages +] + + +## ``section_regexps`` is a list of 2-tuples associating a string label and a +## list of regexp +## +## Commit messages will be classified in sections thanks to this. Section +## titles are the label, and a commit is classified under this section if any +## of the regexps associated is matching. +## +## Please note that ``section_regexps`` will only classify commits and won't +## make any changes to the contents. So you'll probably want to go check +## ``subject_process`` (or ``body_process``) to do some changes to the subject, +## whenever you are tweaking this variable. +## +section_regexps = [ + ('New', [ + r'^[nN]ew\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$', + ]), + ('Changes', [ + r'^[cC]hg\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$', + ]), + ('Fix', [ + r'^[fF]ix\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$', + ]), + + ('Other', None ## Match all lines + ), + +] + + +## ``body_process`` is a callable +## +## This callable will be given the original body and result will +## be used in the changelog. +## +## Available constructs are: +## +## - any python callable that take one txt argument and return txt argument. +## +## - ReSub(pattern, replacement): will apply regexp substitution. +## +## - Indent(chars=" "): will indent the text with the prefix +## Please remember that template engines gets also to modify the text and +## will usually indent themselves the text if needed. +## +## - Wrap(regexp=r"\n\n"): re-wrap text in separate paragraph to fill 80-Columns +## +## - noop: do nothing +## +## - ucfirst: ensure the first letter is uppercase. +## (usually used in the ``subject_process`` pipeline) +## +## - final_dot: ensure text finishes with a dot +## (usually used in the ``subject_process`` pipeline) +## +## - strip: remove any spaces before or after the content of the string +## +## - SetIfEmpty(msg="No commit message."): will set the text to +## whatever given ``msg`` if the current text is empty. +## +## Additionally, you can `pipe` the provided filters, for instance: +#body_process = Wrap(regexp=r'\n(?=\w+\s*:)') | Indent(chars=" ") +#body_process = Wrap(regexp=r'\n(?=\w+\s*:)') +#body_process = noop +body_process = ReSub(r'((^|\n)[A-Z]\w+(-\w+)*: .*(\n\s+.*)*)+$', r'') | strip + + +## ``subject_process`` is a callable +## +## This callable will be given the original subject and result will +## be used in the changelog. +## +## Available constructs are those listed in ``body_process`` doc. +subject_process = (strip | + ReSub(r'^([cC]hg|[fF]ix|[nN]ew)\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n@]*)(@[a-z]+\s+)*$', r'\4') | + SetIfEmpty("No commit message.") | ucfirst | final_dot) + + +## ``tag_filter_regexp`` is a regexp +## +## Tags that will be used for the changelog must match this regexp. +## +tag_filter_regexp = r'^[0-9]+\.[0-9]+(\.[0-9]+)?$' + + +## ``unreleased_version_label`` is a string or a callable that outputs a string +## +## This label will be used as the changelog Title of the last set of changes +## between last valid tag and HEAD if any. +unreleased_version_label = "(unreleased)" + + +## ``output_engine`` is a callable +## +## This will change the output format of the generated changelog file +## +## Available choices are: +## +## - rest_py +## +## Legacy pure python engine, outputs ReSTructured text. +## This is the default. +## +## - mustache() +## +## Template name could be any of the available templates in +## ``templates/mustache/*.tpl``. +## Requires python package ``pystache``. +## Examples: +## - mustache("markdown") +## - mustache("restructuredtext") +## +## - makotemplate() +## +## Template name could be any of the available templates in +## ``templates/mako/*.tpl``. +## Requires python package ``mako``. +## Examples: +## - makotemplate("restructuredtext") +## +#output_engine = rest_py +#output_engine = mustache("restructuredtext") +output_engine = mustache("markdown") +#output_engine = makotemplate("restructuredtext") + + +## ``include_merge`` is a boolean +## +## This option tells git-log whether to include merge commits in the log. +## The default is to include them. +include_merge = False + + +## ``log_encoding`` is a string identifier +## +## This option tells gitchangelog what encoding is outputed by ``git log``. +## The default is to be clever about it: it checks ``git config`` for +## ``i18n.logOutputEncoding``, and if not found will default to git's own +## default: ``utf-8``. +#log_encoding = 'utf-8' + + +## ``publish`` is a callable +## +## Sets what ``gitchangelog`` should do with the output generated by +## the output engine. ``publish`` is a callable taking one argument +## that is an interator on lines from the output engine. +## +## Some helper callable are provided: +## +## Available choices are: +## +## - stdout +## +## Outputs directly to standard output +## (This is the default) +## +## - FileInsertAtFirstRegexMatch(file, pattern, idx=lamda m: m.start()) +## +## Creates a callable that will parse given file for the given +## regex pattern and will insert the output in the file. +## ``idx`` is a callable that receive the matching object and +## must return a integer index point where to insert the +## the output in the file. Default is to return the position of +## the start of the matched string. +## +## - FileRegexSubst(file, pattern, replace, flags) +## +## Apply a replace inplace in the given file. Your regex pattern must +## take care of everything and might be more complex. Check the README +## for a complete copy-pastable example. +## +# publish = FileInsertIntoFirstRegexMatch( +# "CHANGELOG.rst", +# r'/(?P[0-9]+\.[0-9]+(\.[0-9]+)?)\s+\([0-9]+-[0-9]{2}-[0-9]{2}\)\n--+\n/', +# idx=lambda m: m.start(1) +# ) +#publish = stdout + + +## ``revs`` is a list of callable or a list of string +## +## callable will be called to resolve as strings and allow dynamical +## computation of these. The result will be used as revisions for +## gitchangelog (as if directly stated on the command line). This allows +## to filter exaclty which commits will be read by gitchangelog. +## +## To get a full documentation on the format of these strings, please +## refer to the ``git rev-list`` arguments. There are many examples. +## +## Using callables is especially useful, for instance, if you +## are using gitchangelog to generate incrementally your changelog. +## +## Some helpers are provided, you can use them:: +## +## - FileFirstRegexMatch(file, pattern): will return a callable that will +## return the first string match for the given pattern in the given file. +## If you use named sub-patterns in your regex pattern, it'll output only +## the string matching the regex pattern named "rev". +## +## - Caret(rev): will return the rev prefixed by a "^", which is a +## way to remove the given revision and all its ancestor. +## +## Please note that if you provide a rev-list on the command line, it'll +## replace this value (which will then be ignored). +## +## If empty, then ``gitchangelog`` will act as it had to generate a full +## changelog. +## +## The default is to use all commits to make the changelog. +#revs = ["^1.0.3", ] +#revs = [ +# Caret( +# FileFirstRegexMatch( +# "CHANGELOG.rst", +# r"(?P[0-9]+\.[0-9]+(\.[0-9]+)?)\s+\([0-9]+-[0-9]{2}-[0-9]{2}\)\n--+\n")), +# "HEAD" +#] +revs = [] diff --git a/.github/workflows/python-ci-checks.yml b/.github/workflows/python-ci-checks.yml new file mode 100644 index 0000000..63a2e94 --- /dev/null +++ b/.github/workflows/python-ci-checks.yml @@ -0,0 +1,12 @@ +name: Python CI Checks + +on: [push, pull_request] + +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v3 + - run: pip install -e . + - uses: pre-commit/action@v3.0.0 diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml new file mode 100644 index 0000000..aa13f47 --- /dev/null +++ b/.github/workflows/pythonpublish.yml @@ -0,0 +1,27 @@ +name: Upload Python Package + +on: + release: + types: [created] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout code for release + uses: actions/checkout@v1 + - name: Set up Python + uses: actions/setup-python@v1 + with: + python-version: "3.x" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools wheel twine + - name: Build and publish + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + python setup.py sdist bdist_wheel + twine upload dist/* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5eb250f..a1f8850 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,14 +1,14 @@ repos: - repo: https://github.com/psf/black - rev: stable + rev: "23.11.0" hooks: - id: black - language_version: python3.7 - - repo: https://gitlab.com/pycqa/flake8 - rev: "" + language_version: python3 + - repo: https://github.com/pycqa/flake8 + rev: "6.1.0" hooks: - id: flake8 - additional_dependencies: [flake8-docstrings] + additional_dependencies: [flake8-docstrings, flake8-isort] - repo: local hooks: - id: test diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..039a3cd --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,50 @@ +## v0.4.3 + +- Stoping Using Root Logger with One-off Logging Setup #44 + +## v0.4.2 + +- chore(deps): reduce strictness of dependencies #41 + +## v0.4.1 + +- Bumping Version for Aiohttp #38 +- CI runs pre-commit #37 +- Add ability to override init_payload for subscriptions. #36 + +## v0.4.0 + +- Support Advanced Usages #33 + +## v0.3.1 + +- Subscription headers #30 +- Release v0.3.1 #31 + +## v0.3.0 + +- Github Actions status badges πŸ… documentation #15 +- Bug/fix black flake8 conflict #17 +- new: dev: add gitchangelog to generate change log #19 +- feat: add pull_request as trigger for ci checks #20 +- Rewrite async tests without 3rd party library enhancement #21 +- Fix/flake8 consistent checks bug chore #22 +- GraphQL Subscriptions Support enhancement #23 +- Bump the version to 0.3.0 #24 + +## v0.2.0 + +- feat: create pypi publish github action chore #8 +- Allow overriding headers when making requests #10 +- feat: create config for linter checks #11 +- Bump the version to 0.2.0 #13 + +## v0.1.1 + +- Fix/setup fields #1 +- Link to new public repository on contributing guidelines #2 +- Add the Black label πŸŽ–to show our style πŸ’… #3 + +## v0.1.0 + +- First version of the package πŸŽ‰ diff --git a/Makefile b/Makefile index a1d6ab9..ed3bb1a 100644 --- a/Makefile +++ b/Makefile @@ -12,3 +12,11 @@ help: ## List all available commands. .PHONY: tests tests: ## Run the unit tests against the project. python -m unittest discover -s tests/ + +.PHONY: gen-changelog +gen-changelog: ## Generate CHANGELOG.md file. + gitchangelog > CHANGELOG.md + +.PHONY: gen-changelog-delta +gen-changelog-delta: ## Add change log delta to CHANGELOG.md + gitchangelog $(first-tag)..$(last-tag) | cat - CHANGELOG.md > temp && mv temp CHANGELOG.md diff --git a/README.md b/README.md index 5783f7c..4c21aa9 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) +![Python CI Checks](https://github.com/prodigyeducation/python-graphql-client/workflows/Python%20CI%20Checks/badge.svg) +![Upload Python Package](https://github.com/prodigyeducation/python-graphql-client/workflows/Upload%20Python%20Package/badge.svg) # Python GraphQL Client @@ -14,7 +16,9 @@ pip install python-graphql-client ## Usage -```python +### Queries & Mutations + +```py from python_graphql_client import GraphqlClient # Instantiate the client with an endpoint. @@ -43,6 +47,70 @@ data = asyncio.run(client.execute_async(query=query, variables=variables)) print(data) # => {'data': {'country': {'code': 'CA', 'name': 'Canada'}}} ``` +### Subscriptions + +```py +from python_graphql_client import GraphqlClient + +# Instantiate the client with a websocket endpoint. +client = GraphqlClient(endpoint="wss://www.your-api.com/graphql") + +# Create the query string and variables required for the request. +query = """ + subscription onMessageAdded { + messageAdded + } +""" + +# Asynchronous request +import asyncio + +asyncio.run(client.subscribe(query=query, handle=print)) +# => {'data': {'messageAdded': 'Error omnis quis.'}} +# => {'data': {'messageAdded': 'Enim asperiores omnis.'}} +# => {'data': {'messageAdded': 'Unde ullam consequatur quam eius vel.'}} +# ... +``` + +## Advanced Usage + +### Disable SSL verification + +Set the keyword argument `verify=False` ether when instantiating the `GraphqlClient` class. + +```py +from python_graphql_client import GraphqlClient + +client = GraphqlClient(endpoint="wss://www.your-api.com/graphql", verify=False) +``` + +Alternatively, you can set it when calling the `execute` method. + +```py +from python_graphql_client import GraphqlClient + +client = GraphqlClient(endpoint="wss://www.your-api.com/graphql" +client.execute(query="", verify=False) +``` + +### Custom Authentication + +```py +from requests.auth import HTTPBasicAuth +from python_graphql_client import GraphqlClient + +auth = HTTPBasicAuth('fake@example.com', 'not_a_real_password') +client = GraphqlClient(endpoint="wss://www.your-api.com/graphql", auth=auth) +``` + +### Custom Headers +```py +from python_graphql_client import GraphqlClient + +headers = { "Authorization": "Token SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV" } +client = GraphqlClient(endpoint="wss://www.your-api.com/graphql", headers=headers) +``` + ## Roadmap To start we'll try and use a Github project board for listing current work and updating priorities of upcoming features. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 833edb1..71fad3d 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -28,7 +28,7 @@ git clone git@github.com:prodigyeducation/python-graphql-client.git After that you'll need to install the required python dependencies as well as development dependencies for linting the project. ```bash -pip install -e .["dev"] +pip install -e ."[dev]" ``` Finally, you should make sure `pre-commit` has setup the git hooks on your machine by running the following command. This will run the automated checks every time you commit. diff --git a/python_graphql_client/graphql_client.py b/python_graphql_client/graphql_client.py index 24df29f..4f8388c 100644 --- a/python_graphql_client/graphql_client.py +++ b/python_graphql_client/graphql_client.py @@ -1,16 +1,23 @@ """Module containing graphQL client.""" +import json +import logging +from typing import Any, Callable + import aiohttp import requests +import websockets class GraphqlClient: """Class which represents the interface to make graphQL requests through.""" - def __init__(self, endpoint: str, headers: dict = None): + def __init__(self, endpoint: str, headers: dict = {}, **kwargs: Any): """Insantiate the client.""" + self.logger = logging.getLogger(__name__) self.endpoint = endpoint - self.headers = headers or {} + self.headers = headers + self.options = kwargs def __request_body( self, query: str, variables: dict = None, operation_name: str = None @@ -25,18 +32,36 @@ def __request_body( return json - def execute(self, query: str, variables: dict = None, operation_name: str = None): + def execute( + self, + query: str, + variables: dict = None, + operation_name: str = None, + headers: dict = {}, + **kwargs: Any, + ): """Make synchronous request to graphQL server.""" request_body = self.__request_body( query=query, variables=variables, operation_name=operation_name ) - result = requests.post(self.endpoint, json=request_body, headers=self.headers) + result = requests.post( + self.endpoint, + json=request_body, + headers={**self.headers, **headers}, + **{**self.options, **kwargs}, + ) + result.raise_for_status() return result.json() async def execute_async( - self, query: str, variables: dict = None, operation_name: str = None + self, + query: str, + variables: dict = None, + operation_name: str = None, + headers: dict = {}, + **kwargs: Any, ): """Make asynchronous request to graphQL server.""" request_body = self.__request_body( @@ -45,6 +70,49 @@ async def execute_async( async with aiohttp.ClientSession() as session: async with session.post( - self.endpoint, json=request_body, headers=self.headers + self.endpoint, + **{ + **self.options, + **kwargs, + "headers": {**self.headers, **headers}, + "json": request_body, + }, ) as response: return await response.json() + + async def subscribe( + self, + query: str, + handle: Callable, + variables: dict = None, + operation_name: str = None, + headers: dict = {}, + init_payload: dict = {}, + ): + """Make asynchronous request for GraphQL subscription.""" + connection_init_message = json.dumps( + {"type": "connection_init", "payload": init_payload} + ) + + request_body = self.__request_body( + query=query, variables=variables, operation_name=operation_name + ) + request_message = json.dumps( + {"type": "start", "id": "1", "payload": request_body} + ) + + async with websockets.connect( + self.endpoint, + subprotocols=["graphql-ws"], + extra_headers={**self.headers, **headers}, + ) as websocket: + await websocket.send(connection_init_message) + await websocket.send(request_message) + async for response_message in websocket: + response_body = json.loads(response_message) + if response_body["type"] == "connection_ack": + self.logger.info("the server accepted the connection") + elif response_body["type"] == "ka": + self.logger.info("the server sent a keep alive message") + else: + handle(response_body["payload"]) diff --git a/setup.py b/setup.py index aa217ef..fcd8f1e 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ setup( name="python_graphql_client", - version="0.1.1", + version="0.4.3", description="Python GraphQL Client", long_description=long_description, long_description_content_type="text/markdown", @@ -29,8 +29,17 @@ author_email="opensource@prodigygame.com", license="MIT", packages=["python_graphql_client"], - install_requires=["aiohttp==3.6.2", "requests==2.22.0", "asynctest==0.13.0"], + install_requires=["aiohttp~=3.0", "requests~=2.0", "websockets>=5.0"], extras_require={ - "dev": ["pre-commit", "black", "flake8", "flake8-black", "flake8-isort"] + "dev": [ + "pre-commit", + "black", + "flake8", + "flake8-docstrings", + "flake8-black", + "flake8-isort", + "gitchangelog", + "pystache", + ] }, ) diff --git a/tests/test_graphql_client.py b/tests/test_graphql_client.py index a536e41..0bbe344 100644 --- a/tests/test_graphql_client.py +++ b/tests/test_graphql_client.py @@ -1,17 +1,16 @@ """Tests for main graphql client module.""" -import unittest -from unittest.mock import MagicMock +from unittest import IsolatedAsyncioTestCase, TestCase +from unittest.mock import AsyncMock, MagicMock, call, patch from aiohttp import web -from aiohttp.test_utils import AioHTTPTestCase, unittest_run_loop -from asynctest import CoroutineMock, patch +from requests.auth import HTTPBasicAuth from requests.exceptions import HTTPError from python_graphql_client import GraphqlClient -class TestGraphqlClientConstructor(unittest.TestCase): +class TestGraphqlClientConstructor(TestCase): """Test cases for the __init__ function in the client class.""" def test_init_client_no_endpoint(self): @@ -34,7 +33,7 @@ def test_init_client_headers(self): self.assertEqual(client.headers, headers) -class TestGraphqlClientExecute(unittest.TestCase): +class TestGraphqlClientExecute(TestCase): """Test cases for the synchronous graphql request function.""" @patch("python_graphql_client.graphql_client.requests") @@ -83,13 +82,40 @@ def test_raises_http_errors_as_exceptions(self, post_mock): @patch("python_graphql_client.graphql_client.requests.post") def test_execute_query_with_headers(self, post_mock): """Sends a graphql POST request with headers.""" - headers = {"Content-Type": "application/json"} - client = GraphqlClient(endpoint="http://www.test-api.com/", headers=headers) + client = GraphqlClient( + endpoint="http://www.test-api.com/", + headers={"Content-Type": "application/json", "Existing": "123"}, + ) query = "" - client.execute(query) + client.execute(query=query, headers={"Existing": "456", "New": "foo"}) post_mock.assert_called_once_with( - "http://www.test-api.com/", json={"query": query}, headers=headers + "http://www.test-api.com/", + json={"query": query}, + headers={ + "Content-Type": "application/json", + "Existing": "456", + "New": "foo", + }, + ) + + @patch("python_graphql_client.graphql_client.requests.post") + def test_execute_query_with_options(self, post_mock): + """Sends a graphql POST request with headers.""" + auth = HTTPBasicAuth("fake@example.com", "not_a_real_password") + client = GraphqlClient( + endpoint="http://www.test-api.com/", + auth=auth, + ) + query = "" + client.execute(query=query, verify=False) + + post_mock.assert_called_once_with( + "http://www.test-api.com/", + json={"query": query}, + headers={}, + auth=HTTPBasicAuth("fake@example.com", "not_a_real_password"), + verify=False, ) @patch("python_graphql_client.graphql_client.requests.post") @@ -119,26 +145,17 @@ def test_execute_query_with_operation_name(self, post_mock): ) -class AsyncMock(MagicMock): - """Utility class for mocking coroutines / async code.""" - - async def __call__(self, *args, **kwargs): - """Pass arguments through in callable function.""" - return super().__call__(*args, **kwargs) - - -class TestGraphqlClientExecuteAsync(AioHTTPTestCase): +class TestGraphqlClientExecuteAsync(IsolatedAsyncioTestCase): """Test cases for the asynchronous graphQL request function.""" async def get_application(self): """Override base class method to properly use async tests.""" return web.Application() - @unittest_run_loop @patch("aiohttp.ClientSession.post") async def test_execute_basic_query(self, mock_post): """Sends a graphql POST request to an endpoint.""" - mock_post.return_value.__aenter__.return_value.json = CoroutineMock() + mock_post.return_value.__aenter__.return_value.json = AsyncMock() client = GraphqlClient(endpoint="http://www.test-api.com/") query = """ { @@ -154,11 +171,38 @@ async def test_execute_basic_query(self, mock_post): "http://www.test-api.com/", json={"query": query}, headers={} ) - @unittest_run_loop + @patch("aiohttp.ClientSession.post") + async def test_execute_basic_query_with_aiohttp_parameters(self, mock_post): + """Sends a graphql POST request to an endpoint.""" + mock_post.return_value.__aenter__.return_value.json = AsyncMock() + client = GraphqlClient(endpoint="http://www.test-api.com/") + query = """ + { + tests { + status + } + } + """ + + await client.execute_async( + query, + timeout=10, + verify_ssl=False, + headers={"Authorization": "Bearer token"}, + ) + + mock_post.assert_called_once_with( + "http://www.test-api.com/", + json={"query": query}, + headers={"Authorization": "Bearer token"}, + timeout=10, + verify_ssl=False, + ) + @patch("aiohttp.ClientSession.post") async def test_execute_query_with_variables(self, mock_post): """Sends a graphql POST request with variables.""" - mock_post.return_value.__aenter__.return_value.json = CoroutineMock() + mock_post.return_value.__aenter__.return_value.json = AsyncMock() client = GraphqlClient(endpoint="http://www.test-api.com/") query = "" variables = {"id": 123} @@ -171,26 +215,32 @@ async def test_execute_query_with_variables(self, mock_post): headers={}, ) - @unittest_run_loop @patch("aiohttp.ClientSession.post") async def test_execute_query_with_headers(self, mock_post): """Sends a graphql POST request with headers.""" - mock_post.return_value.__aenter__.return_value.json = CoroutineMock() - headers = {"Content-Type": "application/json"} - client = GraphqlClient(endpoint="http://www.test-api.com/", headers=headers) + mock_post.return_value.__aenter__.return_value.json = AsyncMock() + client = GraphqlClient( + endpoint="http://www.test-api.com/", + headers={"Content-Type": "application/json", "Existing": "123"}, + ) query = "" - await client.execute_async(query) + await client.execute_async("", headers={"Existing": "456", "New": "foo"}) mock_post.assert_called_once_with( - "http://www.test-api.com/", json={"query": query}, headers=headers + "http://www.test-api.com/", + json={"query": query}, + headers={ + "Content-Type": "application/json", + "Existing": "456", + "New": "foo", + }, ) - @unittest_run_loop @patch("aiohttp.ClientSession.post") async def test_execute_query_with_operation_name(self, mock_post): """Sends a graphql POST request with the operationName key set.""" - mock_post.return_value.__aenter__.return_value.json = CoroutineMock() + mock_post.return_value.__aenter__.return_value.json = AsyncMock() client = GraphqlClient(endpoint="http://www.test-api.com/") query = """ query firstQuery { @@ -216,5 +266,127 @@ async def test_execute_query_with_operation_name(self, mock_post): ) -if __name__ == "__main__": - unittest.main() +class TestGraphqlClientSubscriptions(IsolatedAsyncioTestCase): + """Test cases for subscribing GraphQL subscriptions.""" + + @patch("websockets.connect") + async def test_subscribe(self, mock_connect): + """Subsribe a GraphQL subscription.""" + mock_websocket = mock_connect.return_value.__aenter__.return_value + mock_websocket.send = AsyncMock() + mock_websocket.__aiter__.return_value = [ + '{"type": "data", "id": "1", "payload": {"data": {"messageAdded": "one"}}}', + '{"type": "data", "id": "1", "payload": {"data": {"messageAdded": "two"}}}', + ] + + client = GraphqlClient(endpoint="ws://www.test-api.com/graphql") + query = """ + subscription onMessageAdded { + messageAdded + } + """ + + mock_handle = MagicMock() + + await client.subscribe(query=query, handle=mock_handle) + + mock_handle.assert_has_calls( + [ + call({"data": {"messageAdded": "one"}}), + call({"data": {"messageAdded": "two"}}), + ] + ) + + @patch("logging.getLogger") + @patch("websockets.connect") + async def test_does_not_crash_with_keep_alive(self, mock_connect, mock_get_logger): + """Subsribe a GraphQL subscription.""" + mock_websocket = mock_connect.return_value.__aenter__.return_value + mock_websocket.send = AsyncMock() + mock_websocket.__aiter__.return_value = [ + '{"type": "ka"}', + ] + + client = GraphqlClient(endpoint="ws://www.test-api.com/graphql") + query = """ + subscription onMessageAdded { + messageAdded + } + """ + + await client.subscribe(query=query, handle=MagicMock()) + + mock_get_logger.return_value.info.assert_has_calls( + [call("the server sent a keep alive message")] + ) + + @patch("websockets.connect") + async def test_headers_passed_to_websocket_connect(self, mock_connect): + """Subsribe a GraphQL subscription.""" + mock_websocket = mock_connect.return_value.__aenter__.return_value + mock_websocket.send = AsyncMock() + mock_websocket.__aiter__.return_value = [ + '{"type": "data", "id": "1", "payload": {"data": {"messageAdded": "one"}}}', + ] + + expected_endpoint = "ws://www.test-api.com/graphql" + client = GraphqlClient(endpoint=expected_endpoint) + + query = """ + subscription onMessageAdded { + messageAdded + } + """ + + mock_handle = MagicMock() + + expected_headers = {"some": "header"} + + await client.subscribe( + query=query, handle=mock_handle, headers=expected_headers + ) + + mock_connect.assert_called_with( + expected_endpoint, + subprotocols=["graphql-ws"], + extra_headers=expected_headers, + ) + + mock_handle.assert_has_calls([call({"data": {"messageAdded": "one"}})]) + + @patch("websockets.connect") + async def test_init_payload_passed_in_init_message(self, mock_connect): + """Subsribe a GraphQL subscription.""" + mock_websocket = mock_connect.return_value.__aenter__.return_value + mock_websocket.send = AsyncMock() + mock_websocket.__aiter__.return_value = [ + '{"type": "connection_init", "payload": ' + '{"init": "this is the init_payload"}}', + '{"type": "data", "id": "1", "payload": {"data": {"messageAdded": "one"}}}', + ] + expected_endpoint = "ws://www.test-api.com/graphql" + client = GraphqlClient(endpoint=expected_endpoint) + + query = """ + subscription onMessageAdded { + messageAdded + } + """ + init_payload = '{"init": "this is the init_payload"}' + + mock_handle = MagicMock() + + await client.subscribe( + query=query, handle=mock_handle, init_payload=init_payload + ) + + mock_connect.assert_called_with( + expected_endpoint, subprotocols=["graphql-ws"], extra_headers={} + ) + + mock_handle.assert_has_calls( + [ + call({"init": "this is the init_payload"}), + call({"data": {"messageAdded": "one"}}), + ] + )