├── setup.cfg ├── python_graphql_client ├── __init__.py └── graphql_client.py ├── .github ├── workflows │ ├── python-ci-checks.yml │ └── pythonpublish.yml ├── pull_request_template.md └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .pre-commit-config.yaml ├── .makefile.inc ├── Makefile ├── LICENSE ├── .makefile.identity.inc ├── CHANGELOG.md ├── setup.py ├── .gitignore ├── docs ├── CONTRIBUTING.md └── CODE_OF_CONDUCT.md ├── README.md ├── .gitchangelog.rc └── tests └── test_graphql_client.py /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | extend-ignore = 4 | # See https://github.com/PyCQA/pycodestyle/issues/373 5 | E203, 6 | no-isort-config = True 7 | -------------------------------------------------------------------------------- /python_graphql_client/__init__.py: -------------------------------------------------------------------------------- 1 | """Things to export as part of package.""" 2 | 3 | # Ignore flake8 unused import rule. 4 | from .graphql_client import GraphqlClient # noqa: F401 5 | -------------------------------------------------------------------------------- /.github/workflows/python-ci-checks.yml: -------------------------------------------------------------------------------- 1 | name: Python CI Checks 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | pre-commit: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - uses: actions/setup-python@v3 11 | - run: pip install -e . 12 | - uses: pre-commit/action@v3.0.0 13 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## What kind of change does this PR introduce? 2 | 3 | 4 | 5 | 6 | 7 | ## What is the current behavior? 8 | 9 | 10 | 11 | 12 | 13 | ## What is the new behavior? 14 | 15 | 16 | 17 | ## **Does this PR introduce a breaking change?** 18 | 19 | 20 | 21 | 22 | 23 | ## Other information 24 | 25 | 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | 12 | 13 | **To Reproduce** 14 | 15 | 16 | **Expected behavior** 17 | 18 | 19 | **Additional context** 20 | 21 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: "23.11.0" 4 | hooks: 5 | - id: black 6 | language_version: python3 7 | - repo: https://github.com/pycqa/flake8 8 | rev: "6.1.0" 9 | hooks: 10 | - id: flake8 11 | additional_dependencies: [flake8-docstrings, flake8-isort] 12 | - repo: local 13 | hooks: 14 | - id: test 15 | name: test 16 | entry: python -m unittest discover tests/ 17 | pass_filenames: false 18 | language: system 19 | types: [python] 20 | -------------------------------------------------------------------------------- /.makefile.inc: -------------------------------------------------------------------------------- 1 | # Available colors 2 | RED := $(shell tput -Txterm setaf 1) 3 | GREEN := $(shell tput -Txterm setaf 2) 4 | YELLOW := $(shell tput -Txterm setaf 3) 5 | WHITE := $(shell tput -Txterm setaf 7) 6 | RESET := $(shell tput -Txterm sgr0) 7 | 8 | 9 | define HELP_SCRIPT 10 | if ((/^```/ && $$p > 0) || /^```ascii/) { $$p++; next }; 11 | print $$_ if ($$p == 1); 12 | if (/^([A-Za-z0-9_-]+[%]?)*:.*## (.*)/) {printf "${YELLOW}%-${TARGET_MAX_CHAR_NUM}s${GREEN}%s${RESET}\n", $$1, $$2 }; 13 | if (/^###?/) { printf "\n" } 14 | endef 15 | export HELP_SCRIPT 16 | 17 | define SHOW_IDENTITY 18 | @[ -f ./.makefile.identity.inc ] && cat ./.makefile.identity.inc; echo ''; echo '' || echo '' 19 | endef -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | include .makefile.inc 2 | 3 | .PHONY: help 4 | help: ## List all available commands. 5 | ${SHOW_IDENTITY} 6 | @echo 'Usage:' 7 | @echo '${YELLOW}make${RESET} ${GREEN}${RESET}' 8 | @echo '' 9 | @echo 'Targets:' 10 | @perl -ne "$${HELP_SCRIPT}" $(MAKEFILE_LIST) 11 | 12 | .PHONY: tests 13 | tests: ## Run the unit tests against the project. 14 | python -m unittest discover -s tests/ 15 | 16 | .PHONY: gen-changelog 17 | gen-changelog: ## Generate CHANGELOG.md file. 18 | gitchangelog > CHANGELOG.md 19 | 20 | .PHONY: gen-changelog-delta 21 | gen-changelog-delta: ## Add change log delta to CHANGELOG.md 22 | gitchangelog $(first-tag)..$(last-tag) | cat - CHANGELOG.md > temp && mv temp CHANGELOG.md 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | 12 | 13 | **Describe the solution you'd like** 14 | 15 | 16 | **Describe alternatives you've considered** 17 | 18 | 19 | **Additional context** 20 | 21 | -------------------------------------------------------------------------------- /.github/workflows/pythonpublish.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout code for release 12 | uses: actions/checkout@v1 13 | - name: Set up Python 14 | uses: actions/setup-python@v1 15 | with: 16 | python-version: "3.x" 17 | - name: Install dependencies 18 | run: | 19 | python -m pip install --upgrade pip 20 | pip install setuptools wheel twine 21 | - name: Build and publish 22 | env: 23 | TWINE_USERNAME: ${{ secrets.PYPI_USER }} 24 | TWINE_PASSWORD: ${{ secrets.PYPI_PASS }} 25 | run: | 26 | python setup.py sdist bdist_wheel 27 | twine upload dist/* 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Prodigy Education Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /.makefile.identity.inc: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | PPPPPPPPPPPPPPPPP QQQQQQQQQ CCCCCCCCCCCCC 5 | P::::::::::::::::P QQ:::::::::QQ CCC::::::::::::C 6 | P::::::PPPPPP:::::P QQ:::::::::::::QQ CC:::::::::::::::C 7 | PP:::::P P:::::PQ:::::::QQQ:::::::Q C:::::CCCCCCCC::::C 8 | P::::P P:::::PQ::::::O Q::::::Q C:::::C CCCCCC 9 | P::::P P:::::PQ:::::O Q:::::QC:::::C 10 | P::::PPPPPP:::::P Q:::::O Q:::::QC:::::C 11 | P:::::::::::::PP Q:::::O Q:::::QC:::::C 12 | P::::PPPPPPPPP Q:::::O Q:::::QC:::::C 13 | P::::P Q:::::O Q:::::QC:::::C 14 | P::::P Q:::::O QQQQ:::::QC:::::C 15 | P::::P Q::::::O Q::::::::Q C:::::C CCCCCC 16 | PP::::::PP Q:::::::QQ::::::::Q C:::::CCCCCCCC::::C 17 | P::::::::P QQ::::::::::::::Q CC:::::::::::::::C 18 | P::::::::P QQ:::::::::::Q CCC::::::::::::C 19 | PPPPPPPPPP QQQQQQQQ::::QQ CCCCCCCCCCCCC 20 | Q:::::Q 21 | QQQQQQ 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v0.4.3 2 | 3 | - Stoping Using Root Logger with One-off Logging Setup #44 4 | 5 | ## v0.4.2 6 | 7 | - chore(deps): reduce strictness of dependencies #41 8 | 9 | ## v0.4.1 10 | 11 | - Bumping Version for Aiohttp #38 12 | - CI runs pre-commit #37 13 | - Add ability to override init_payload for subscriptions. #36 14 | 15 | ## v0.4.0 16 | 17 | - Support Advanced Usages #33 18 | 19 | ## v0.3.1 20 | 21 | - Subscription headers #30 22 | - Release v0.3.1 #31 23 | 24 | ## v0.3.0 25 | 26 | - Github Actions status badges 🏅 documentation #15 27 | - Bug/fix black flake8 conflict #17 28 | - new: dev: add gitchangelog to generate change log #19 29 | - feat: add pull_request as trigger for ci checks #20 30 | - Rewrite async tests without 3rd party library enhancement #21 31 | - Fix/flake8 consistent checks bug chore #22 32 | - GraphQL Subscriptions Support enhancement #23 33 | - Bump the version to 0.3.0 #24 34 | 35 | ## v0.2.0 36 | 37 | - feat: create pypi publish github action chore #8 38 | - Allow overriding headers when making requests #10 39 | - feat: create config for linter checks #11 40 | - Bump the version to 0.2.0 #13 41 | 42 | ## v0.1.1 43 | 44 | - Fix/setup fields #1 45 | - Link to new public repository on contributing guidelines #2 46 | - Add the Black label 🎖to show our style 💅 #3 47 | 48 | ## v0.1.0 49 | 50 | - First version of the package 🎉 51 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Configuration for python project.""" 2 | 3 | from os import path 4 | 5 | from setuptools import setup 6 | 7 | this_directory = path.abspath(path.dirname(__file__)) 8 | with open(path.join(this_directory, "README.md"), encoding="utf-8") as f: 9 | long_description = f.read() 10 | 11 | setup( 12 | name="python_graphql_client", 13 | version="0.4.3", 14 | description="Python GraphQL Client", 15 | long_description=long_description, 16 | long_description_content_type="text/markdown", 17 | classifiers=[ 18 | "Development Status :: 3 - Alpha", 19 | "Intended Audience :: Developers", 20 | "Topic :: Software Development :: Libraries", 21 | "License :: OSI Approved :: MIT License", 22 | "Programming Language :: Python :: 3.6", 23 | "Programming Language :: Python :: 3.7", 24 | "Programming Language :: Python :: 3.8", 25 | ], 26 | keywords="api graphql client", 27 | url="https://github.com/prodigyeducation/python-graphql-client", 28 | author="Justin Krinke", 29 | author_email="opensource@prodigygame.com", 30 | license="MIT", 31 | packages=["python_graphql_client"], 32 | install_requires=["aiohttp~=3.0", "requests~=2.0", "websockets>=5.0"], 33 | extras_require={ 34 | "dev": [ 35 | "pre-commit", 36 | "black", 37 | "flake8", 38 | "flake8-docstrings", 39 | "flake8-black", 40 | "flake8-isort", 41 | "gitchangelog", 42 | "pystache", 43 | ] 44 | }, 45 | ) 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /docs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | First off, thanks for even considering to contribute to this project! 4 | 5 | The goal of this document is to hopefully fill in any gaps in knowledge for how you can help. 6 | 7 | # Table of Contents 8 | 9 | - [Introduction](#introduction) 10 | - [Table of Contents](#table-of-contents) 11 | - [Getting started](#getting-started) 12 | - [Testing](#testing) 13 | - [How to submit changes](#how-to-submit-changes) 14 | - [How to report a bug](#how-to-report-a-bug) 15 | - [How to request an enhancement](#how-to-request-an-enhancement) 16 | - [Style Guide](#style-guide) 17 | - [Code of Conduct](#code-of-conduct) 18 | - [Where can I ask for help?](#where-can-i-ask-for-help) 19 | 20 | # Getting started 21 | 22 | First thing you'll need to do is clone the project. 23 | 24 | ```bash 25 | git clone git@github.com:prodigyeducation/python-graphql-client.git 26 | ``` 27 | 28 | After that you'll need to install the required python dependencies as well as development dependencies for linting the project. 29 | 30 | ```bash 31 | pip install -e ."[dev]" 32 | ``` 33 | 34 | 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. 35 | 36 | ```bash 37 | pre-commit install 38 | ``` 39 | 40 | # Testing 41 | 42 | Once the above setup is complete, you can run the tests with the following command. 43 | 44 | ```bash 45 | make tests 46 | ``` 47 | 48 | # How to submit changes 49 | 50 | 1. Create a fork of the repository. Checkout this [github article](https://help.github.com/en/github/getting-started-with-github/fork-a-repo) for more information. 51 | 2. Make changes in your fork of the project. 52 | 3. Open a pull request to merge the changes in your fork back into the main repository. Follow the pull request template with details about the changes you're adding. 53 | 54 | # How to report a bug 55 | 56 | 1. Do a scan of the [issues](https://github.com/prodigyeducation/python-graphql-client/issues) on the github repository to see if someone else has already opened an issue similar to yours. 57 | 2. Create an issue on github using the `Bug report` template. 58 | 3. Fill out the issue template with relevant details. 59 | 60 | # How to request an enhancement 61 | 62 | 1. Do a scan of the [issues](https://github.com/prodigyeducation/python-graphql-client/issues) on the github repository to see if someone else has already opened an issue similar to yours. 63 | 2. Create an issue on github using the `Feature request` template. 64 | 3. Fill out the issue template with relevant details. 65 | 66 | # Style Guide 67 | 68 | This project uses some automated linter and style checks to make sure the code quality stays at an acceptable level. All the checks used in this project should run when you commit (thanks to pre-commit and git hooks) and remotely when creating the pull request (thanks to github actions). 69 | 70 | # Code of Conduct 71 | 72 | Take a peek at the [Code of Conduct](CODE_OF_CONDUCT.md) document for more information. 73 | 74 | # Where can I ask for help? 75 | 76 | This project is pretty new, so for now feel free to add an issue into the project. Otherwise you can always email us at opensource@prodigygame.com for questions. Maybe one day we'll create a slack group for this project. 77 | -------------------------------------------------------------------------------- /docs/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at opensource@prodigygame.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 2 | ![Python CI Checks](https://github.com/prodigyeducation/python-graphql-client/workflows/Python%20CI%20Checks/badge.svg) 3 | ![Upload Python Package](https://github.com/prodigyeducation/python-graphql-client/workflows/Upload%20Python%20Package/badge.svg) 4 | 5 | # Python GraphQL Client 6 | 7 | > Simple package for making requests to a graphql server. 8 | 9 | 10 | 11 | ## Installation 12 | 13 | ```bash 14 | pip install python-graphql-client 15 | ``` 16 | 17 | ## Usage 18 | 19 | ### Queries & Mutations 20 | 21 | ```py 22 | from python_graphql_client import GraphqlClient 23 | 24 | # Instantiate the client with an endpoint. 25 | client = GraphqlClient(endpoint="https://countries.trevorblades.com") 26 | 27 | # Create the query string and variables required for the request. 28 | query = """ 29 | query countryQuery($countryCode: String) { 30 | country(code:$countryCode) { 31 | code 32 | name 33 | } 34 | } 35 | """ 36 | variables = {"countryCode": "CA"} 37 | 38 | # Synchronous request 39 | data = client.execute(query=query, variables=variables) 40 | print(data) # => {'data': {'country': {'code': 'CA', 'name': 'Canada'}}} 41 | 42 | 43 | # Asynchronous request 44 | import asyncio 45 | 46 | data = asyncio.run(client.execute_async(query=query, variables=variables)) 47 | print(data) # => {'data': {'country': {'code': 'CA', 'name': 'Canada'}}} 48 | ``` 49 | 50 | ### Subscriptions 51 | 52 | ```py 53 | from python_graphql_client import GraphqlClient 54 | 55 | # Instantiate the client with a websocket endpoint. 56 | client = GraphqlClient(endpoint="wss://www.your-api.com/graphql") 57 | 58 | # Create the query string and variables required for the request. 59 | query = """ 60 | subscription onMessageAdded { 61 | messageAdded 62 | } 63 | """ 64 | 65 | # Asynchronous request 66 | import asyncio 67 | 68 | asyncio.run(client.subscribe(query=query, handle=print)) 69 | # => {'data': {'messageAdded': 'Error omnis quis.'}} 70 | # => {'data': {'messageAdded': 'Enim asperiores omnis.'}} 71 | # => {'data': {'messageAdded': 'Unde ullam consequatur quam eius vel.'}} 72 | # ... 73 | ``` 74 | 75 | ## Advanced Usage 76 | 77 | ### Disable SSL verification 78 | 79 | Set the keyword argument `verify=False` ether when instantiating the `GraphqlClient` class. 80 | 81 | ```py 82 | from python_graphql_client import GraphqlClient 83 | 84 | client = GraphqlClient(endpoint="wss://www.your-api.com/graphql", verify=False) 85 | ``` 86 | 87 | Alternatively, you can set it when calling the `execute` method. 88 | 89 | ```py 90 | from python_graphql_client import GraphqlClient 91 | 92 | client = GraphqlClient(endpoint="wss://www.your-api.com/graphql" 93 | client.execute(query="", verify=False) 94 | ``` 95 | 96 | ### Custom Authentication 97 | 98 | ```py 99 | from requests.auth import HTTPBasicAuth 100 | from python_graphql_client import GraphqlClient 101 | 102 | auth = HTTPBasicAuth('fake@example.com', 'not_a_real_password') 103 | client = GraphqlClient(endpoint="wss://www.your-api.com/graphql", auth=auth) 104 | ``` 105 | 106 | ### Custom Headers 107 | ```py 108 | from python_graphql_client import GraphqlClient 109 | 110 | headers = { "Authorization": "Token SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV" } 111 | client = GraphqlClient(endpoint="wss://www.your-api.com/graphql", headers=headers) 112 | ``` 113 | 114 | ## Roadmap 115 | 116 | To start we'll try and use a Github project board for listing current work and updating priorities of upcoming features. 117 | 118 | ## Contributing 119 | 120 | Read the [Contributing](docs/CONTRIBUTING.md) documentation for details on the process for submitting pull requests to the project. Also take a peek at our [Code of Conduct](docs/CODE_OF_CONDUCT.md). 121 | 122 | ## Authors and Acknowledgement 123 | 124 | Kudos to @xkludge, @DaleSeo, and @mattbullock for getting this project started. 125 | 126 | ## License 127 | 128 | [MIT License](LICENSE) 129 | -------------------------------------------------------------------------------- /python_graphql_client/graphql_client.py: -------------------------------------------------------------------------------- 1 | """Module containing graphQL client.""" 2 | 3 | import json 4 | import logging 5 | from typing import Any, Callable 6 | 7 | import aiohttp 8 | import requests 9 | import websockets 10 | 11 | 12 | class GraphqlClient: 13 | """Class which represents the interface to make graphQL requests through.""" 14 | 15 | def __init__(self, endpoint: str, headers: dict = {}, **kwargs: Any): 16 | """Insantiate the client.""" 17 | self.logger = logging.getLogger(__name__) 18 | self.endpoint = endpoint 19 | self.headers = headers 20 | self.options = kwargs 21 | 22 | def __request_body( 23 | self, query: str, variables: dict = None, operation_name: str = None 24 | ) -> dict: 25 | json = {"query": query} 26 | 27 | if variables: 28 | json["variables"] = variables 29 | 30 | if operation_name: 31 | json["operationName"] = operation_name 32 | 33 | return json 34 | 35 | def execute( 36 | self, 37 | query: str, 38 | variables: dict = None, 39 | operation_name: str = None, 40 | headers: dict = {}, 41 | **kwargs: Any, 42 | ): 43 | """Make synchronous request to graphQL server.""" 44 | request_body = self.__request_body( 45 | query=query, variables=variables, operation_name=operation_name 46 | ) 47 | 48 | result = requests.post( 49 | self.endpoint, 50 | json=request_body, 51 | headers={**self.headers, **headers}, 52 | **{**self.options, **kwargs}, 53 | ) 54 | 55 | result.raise_for_status() 56 | return result.json() 57 | 58 | async def execute_async( 59 | self, 60 | query: str, 61 | variables: dict = None, 62 | operation_name: str = None, 63 | headers: dict = {}, 64 | **kwargs: Any, 65 | ): 66 | """Make asynchronous request to graphQL server.""" 67 | request_body = self.__request_body( 68 | query=query, variables=variables, operation_name=operation_name 69 | ) 70 | 71 | async with aiohttp.ClientSession() as session: 72 | async with session.post( 73 | self.endpoint, 74 | **{ 75 | **self.options, 76 | **kwargs, 77 | "headers": {**self.headers, **headers}, 78 | "json": request_body, 79 | }, 80 | ) as response: 81 | return await response.json() 82 | 83 | async def subscribe( 84 | self, 85 | query: str, 86 | handle: Callable, 87 | variables: dict = None, 88 | operation_name: str = None, 89 | headers: dict = {}, 90 | init_payload: dict = {}, 91 | ): 92 | """Make asynchronous request for GraphQL subscription.""" 93 | connection_init_message = json.dumps( 94 | {"type": "connection_init", "payload": init_payload} 95 | ) 96 | 97 | request_body = self.__request_body( 98 | query=query, variables=variables, operation_name=operation_name 99 | ) 100 | request_message = json.dumps( 101 | {"type": "start", "id": "1", "payload": request_body} 102 | ) 103 | 104 | async with websockets.connect( 105 | self.endpoint, 106 | subprotocols=["graphql-ws"], 107 | extra_headers={**self.headers, **headers}, 108 | ) as websocket: 109 | await websocket.send(connection_init_message) 110 | await websocket.send(request_message) 111 | async for response_message in websocket: 112 | response_body = json.loads(response_message) 113 | if response_body["type"] == "connection_ack": 114 | self.logger.info("the server accepted the connection") 115 | elif response_body["type"] == "ka": 116 | self.logger.info("the server sent a keep alive message") 117 | else: 118 | handle(response_body["payload"]) 119 | -------------------------------------------------------------------------------- /.gitchangelog.rc: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8; mode: python -*- 2 | ## 3 | ## Format 4 | ## 5 | ## ACTION: [AUDIENCE:] COMMIT_MSG [!TAG ...] 6 | ## 7 | ## Description 8 | ## 9 | ## ACTION is one of 'chg', 'fix', 'new' 10 | ## 11 | ## Is WHAT the change is about. 12 | ## 13 | ## 'chg' is for refactor, small improvement, cosmetic changes... 14 | ## 'fix' is for bug fixes 15 | ## 'new' is for new features, big improvement 16 | ## 17 | ## AUDIENCE is optional and one of 'dev', 'usr', 'pkg', 'test', 'doc' 18 | ## 19 | ## Is WHO is concerned by the change. 20 | ## 21 | ## 'dev' is for developpers (API changes, refactors...) 22 | ## 'usr' is for final users (UI changes) 23 | ## 'pkg' is for packagers (packaging changes) 24 | ## 'test' is for testers (test only related changes) 25 | ## 'doc' is for doc guys (doc only changes) 26 | ## 27 | ## COMMIT_MSG is ... well ... the commit message itself. 28 | ## 29 | ## TAGs are additionnal adjective as 'refactor' 'minor' 'cosmetic' 30 | ## 31 | ## They are preceded with a '!' or a '@' (prefer the former, as the 32 | ## latter is wrongly interpreted in github.) Commonly used tags are: 33 | ## 34 | ## 'refactor' is obviously for refactoring code only 35 | ## 'minor' is for a very meaningless change (a typo, adding a comment) 36 | ## 'cosmetic' is for cosmetic driven change (re-indentation, 80-col...) 37 | ## 'wip' is for partial functionality but complete subfunctionality. 38 | ## 39 | ## Example: 40 | ## 41 | ## new: usr: support of bazaar implemented 42 | ## chg: re-indentend some lines !cosmetic 43 | ## new: dev: updated code to be compatible with last version of killer lib. 44 | ## fix: pkg: updated year of licence coverage. 45 | ## new: test: added a bunch of test around user usability of feature X. 46 | ## fix: typo in spelling my name in comment. !minor 47 | ## 48 | ## Please note that multi-line commit message are supported, and only the 49 | ## first line will be considered as the "summary" of the commit message. So 50 | ## tags, and other rules only applies to the summary. The body of the commit 51 | ## message will be displayed in the changelog without reformatting. 52 | 53 | 54 | ## 55 | ## ``ignore_regexps`` is a line of regexps 56 | ## 57 | ## Any commit having its full commit message matching any regexp listed here 58 | ## will be ignored and won't be reported in the changelog. 59 | ## 60 | ignore_regexps = [ 61 | r'@minor', r'!minor', 62 | r'@cosmetic', r'!cosmetic', 63 | r'@refactor', r'!refactor', 64 | r'@wip', r'!wip', 65 | r'^([cC]hg|[fF]ix|[nN]ew)\s*:\s*[p|P]kg:', 66 | r'^([cC]hg|[fF]ix|[nN]ew)\s*:\s*[d|D]ev:', 67 | r'^(.{3,3}\s*:)?\s*[fF]irst commit.?\s*$', 68 | r'^$', ## ignore commits with empty messages 69 | ] 70 | 71 | 72 | ## ``section_regexps`` is a list of 2-tuples associating a string label and a 73 | ## list of regexp 74 | ## 75 | ## Commit messages will be classified in sections thanks to this. Section 76 | ## titles are the label, and a commit is classified under this section if any 77 | ## of the regexps associated is matching. 78 | ## 79 | ## Please note that ``section_regexps`` will only classify commits and won't 80 | ## make any changes to the contents. So you'll probably want to go check 81 | ## ``subject_process`` (or ``body_process``) to do some changes to the subject, 82 | ## whenever you are tweaking this variable. 83 | ## 84 | section_regexps = [ 85 | ('New', [ 86 | r'^[nN]ew\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$', 87 | ]), 88 | ('Changes', [ 89 | r'^[cC]hg\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$', 90 | ]), 91 | ('Fix', [ 92 | r'^[fF]ix\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n]*)$', 93 | ]), 94 | 95 | ('Other', None ## Match all lines 96 | ), 97 | 98 | ] 99 | 100 | 101 | ## ``body_process`` is a callable 102 | ## 103 | ## This callable will be given the original body and result will 104 | ## be used in the changelog. 105 | ## 106 | ## Available constructs are: 107 | ## 108 | ## - any python callable that take one txt argument and return txt argument. 109 | ## 110 | ## - ReSub(pattern, replacement): will apply regexp substitution. 111 | ## 112 | ## - Indent(chars=" "): will indent the text with the prefix 113 | ## Please remember that template engines gets also to modify the text and 114 | ## will usually indent themselves the text if needed. 115 | ## 116 | ## - Wrap(regexp=r"\n\n"): re-wrap text in separate paragraph to fill 80-Columns 117 | ## 118 | ## - noop: do nothing 119 | ## 120 | ## - ucfirst: ensure the first letter is uppercase. 121 | ## (usually used in the ``subject_process`` pipeline) 122 | ## 123 | ## - final_dot: ensure text finishes with a dot 124 | ## (usually used in the ``subject_process`` pipeline) 125 | ## 126 | ## - strip: remove any spaces before or after the content of the string 127 | ## 128 | ## - SetIfEmpty(msg="No commit message."): will set the text to 129 | ## whatever given ``msg`` if the current text is empty. 130 | ## 131 | ## Additionally, you can `pipe` the provided filters, for instance: 132 | #body_process = Wrap(regexp=r'\n(?=\w+\s*:)') | Indent(chars=" ") 133 | #body_process = Wrap(regexp=r'\n(?=\w+\s*:)') 134 | #body_process = noop 135 | body_process = ReSub(r'((^|\n)[A-Z]\w+(-\w+)*: .*(\n\s+.*)*)+$', r'') | strip 136 | 137 | 138 | ## ``subject_process`` is a callable 139 | ## 140 | ## This callable will be given the original subject and result will 141 | ## be used in the changelog. 142 | ## 143 | ## Available constructs are those listed in ``body_process`` doc. 144 | subject_process = (strip | 145 | ReSub(r'^([cC]hg|[fF]ix|[nN]ew)\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n@]*)(@[a-z]+\s+)*$', r'\4') | 146 | SetIfEmpty("No commit message.") | ucfirst | final_dot) 147 | 148 | 149 | ## ``tag_filter_regexp`` is a regexp 150 | ## 151 | ## Tags that will be used for the changelog must match this regexp. 152 | ## 153 | tag_filter_regexp = r'^[0-9]+\.[0-9]+(\.[0-9]+)?$' 154 | 155 | 156 | ## ``unreleased_version_label`` is a string or a callable that outputs a string 157 | ## 158 | ## This label will be used as the changelog Title of the last set of changes 159 | ## between last valid tag and HEAD if any. 160 | unreleased_version_label = "(unreleased)" 161 | 162 | 163 | ## ``output_engine`` is a callable 164 | ## 165 | ## This will change the output format of the generated changelog file 166 | ## 167 | ## Available choices are: 168 | ## 169 | ## - rest_py 170 | ## 171 | ## Legacy pure python engine, outputs ReSTructured text. 172 | ## This is the default. 173 | ## 174 | ## - mustache() 175 | ## 176 | ## Template name could be any of the available templates in 177 | ## ``templates/mustache/*.tpl``. 178 | ## Requires python package ``pystache``. 179 | ## Examples: 180 | ## - mustache("markdown") 181 | ## - mustache("restructuredtext") 182 | ## 183 | ## - makotemplate() 184 | ## 185 | ## Template name could be any of the available templates in 186 | ## ``templates/mako/*.tpl``. 187 | ## Requires python package ``mako``. 188 | ## Examples: 189 | ## - makotemplate("restructuredtext") 190 | ## 191 | #output_engine = rest_py 192 | #output_engine = mustache("restructuredtext") 193 | output_engine = mustache("markdown") 194 | #output_engine = makotemplate("restructuredtext") 195 | 196 | 197 | ## ``include_merge`` is a boolean 198 | ## 199 | ## This option tells git-log whether to include merge commits in the log. 200 | ## The default is to include them. 201 | include_merge = False 202 | 203 | 204 | ## ``log_encoding`` is a string identifier 205 | ## 206 | ## This option tells gitchangelog what encoding is outputed by ``git log``. 207 | ## The default is to be clever about it: it checks ``git config`` for 208 | ## ``i18n.logOutputEncoding``, and if not found will default to git's own 209 | ## default: ``utf-8``. 210 | #log_encoding = 'utf-8' 211 | 212 | 213 | ## ``publish`` is a callable 214 | ## 215 | ## Sets what ``gitchangelog`` should do with the output generated by 216 | ## the output engine. ``publish`` is a callable taking one argument 217 | ## that is an interator on lines from the output engine. 218 | ## 219 | ## Some helper callable are provided: 220 | ## 221 | ## Available choices are: 222 | ## 223 | ## - stdout 224 | ## 225 | ## Outputs directly to standard output 226 | ## (This is the default) 227 | ## 228 | ## - FileInsertAtFirstRegexMatch(file, pattern, idx=lamda m: m.start()) 229 | ## 230 | ## Creates a callable that will parse given file for the given 231 | ## regex pattern and will insert the output in the file. 232 | ## ``idx`` is a callable that receive the matching object and 233 | ## must return a integer index point where to insert the 234 | ## the output in the file. Default is to return the position of 235 | ## the start of the matched string. 236 | ## 237 | ## - FileRegexSubst(file, pattern, replace, flags) 238 | ## 239 | ## Apply a replace inplace in the given file. Your regex pattern must 240 | ## take care of everything and might be more complex. Check the README 241 | ## for a complete copy-pastable example. 242 | ## 243 | # publish = FileInsertIntoFirstRegexMatch( 244 | # "CHANGELOG.rst", 245 | # r'/(?P[0-9]+\.[0-9]+(\.[0-9]+)?)\s+\([0-9]+-[0-9]{2}-[0-9]{2}\)\n--+\n/', 246 | # idx=lambda m: m.start(1) 247 | # ) 248 | #publish = stdout 249 | 250 | 251 | ## ``revs`` is a list of callable or a list of string 252 | ## 253 | ## callable will be called to resolve as strings and allow dynamical 254 | ## computation of these. The result will be used as revisions for 255 | ## gitchangelog (as if directly stated on the command line). This allows 256 | ## to filter exaclty which commits will be read by gitchangelog. 257 | ## 258 | ## To get a full documentation on the format of these strings, please 259 | ## refer to the ``git rev-list`` arguments. There are many examples. 260 | ## 261 | ## Using callables is especially useful, for instance, if you 262 | ## are using gitchangelog to generate incrementally your changelog. 263 | ## 264 | ## Some helpers are provided, you can use them:: 265 | ## 266 | ## - FileFirstRegexMatch(file, pattern): will return a callable that will 267 | ## return the first string match for the given pattern in the given file. 268 | ## If you use named sub-patterns in your regex pattern, it'll output only 269 | ## the string matching the regex pattern named "rev". 270 | ## 271 | ## - Caret(rev): will return the rev prefixed by a "^", which is a 272 | ## way to remove the given revision and all its ancestor. 273 | ## 274 | ## Please note that if you provide a rev-list on the command line, it'll 275 | ## replace this value (which will then be ignored). 276 | ## 277 | ## If empty, then ``gitchangelog`` will act as it had to generate a full 278 | ## changelog. 279 | ## 280 | ## The default is to use all commits to make the changelog. 281 | #revs = ["^1.0.3", ] 282 | #revs = [ 283 | # Caret( 284 | # FileFirstRegexMatch( 285 | # "CHANGELOG.rst", 286 | # r"(?P[0-9]+\.[0-9]+(\.[0-9]+)?)\s+\([0-9]+-[0-9]{2}-[0-9]{2}\)\n--+\n")), 287 | # "HEAD" 288 | #] 289 | revs = [] 290 | -------------------------------------------------------------------------------- /tests/test_graphql_client.py: -------------------------------------------------------------------------------- 1 | """Tests for main graphql client module.""" 2 | 3 | from unittest import IsolatedAsyncioTestCase, TestCase 4 | from unittest.mock import AsyncMock, MagicMock, call, patch 5 | 6 | from aiohttp import web 7 | from requests.auth import HTTPBasicAuth 8 | from requests.exceptions import HTTPError 9 | 10 | from python_graphql_client import GraphqlClient 11 | 12 | 13 | class TestGraphqlClientConstructor(TestCase): 14 | """Test cases for the __init__ function in the client class.""" 15 | 16 | def test_init_client_no_endpoint(self): 17 | """Throws an error if no endpoint is provided to the client.""" 18 | with self.assertRaises(TypeError): 19 | GraphqlClient() 20 | 21 | def test_init_client_endpoint(self): 22 | """Saves the endpoint url as an instance attribute.""" 23 | endpoint = "http://www.test-api.com/" 24 | client = GraphqlClient(endpoint) 25 | 26 | self.assertEqual(client.endpoint, endpoint) 27 | 28 | def test_init_client_headers(self): 29 | """Saves a dictionary of HTTP headers to the instance.""" 30 | headers = {"Content-Type": "application/json"} 31 | client = GraphqlClient(endpoint="", headers=headers) 32 | 33 | self.assertEqual(client.headers, headers) 34 | 35 | 36 | class TestGraphqlClientExecute(TestCase): 37 | """Test cases for the synchronous graphql request function.""" 38 | 39 | @patch("python_graphql_client.graphql_client.requests") 40 | def test_execute_basic_query(self, requests_mock): 41 | """Sends a graphql POST request to an endpoint.""" 42 | client = GraphqlClient(endpoint="http://www.test-api.com/") 43 | query = """ 44 | { 45 | tests { 46 | status 47 | } 48 | } 49 | """ 50 | client.execute(query) 51 | 52 | requests_mock.post.assert_called_once_with( 53 | "http://www.test-api.com/", json={"query": query}, headers={} 54 | ) 55 | 56 | @patch("python_graphql_client.graphql_client.requests") 57 | def test_execute_query_with_variables(self, requests_mock): 58 | """Sends a graphql POST request with variables.""" 59 | client = GraphqlClient(endpoint="http://www.test-api.com/") 60 | query = "" 61 | variables = {"id": 123} 62 | client.execute(query, variables) 63 | 64 | requests_mock.post.assert_called_once_with( 65 | "http://www.test-api.com/", 66 | json={"query": query, "variables": variables}, 67 | headers={}, 68 | ) 69 | 70 | @patch("python_graphql_client.graphql_client.requests.post") 71 | def test_raises_http_errors_as_exceptions(self, post_mock): 72 | """Raises an exception if an http error code is returned in the response.""" 73 | response_mock = MagicMock() 74 | response_mock.raise_for_status.side_effect = HTTPError() 75 | post_mock.return_value = response_mock 76 | 77 | client = GraphqlClient(endpoint="http://www.test-api.com/") 78 | 79 | with self.assertRaises(HTTPError): 80 | client.execute(query="") 81 | 82 | @patch("python_graphql_client.graphql_client.requests.post") 83 | def test_execute_query_with_headers(self, post_mock): 84 | """Sends a graphql POST request with headers.""" 85 | client = GraphqlClient( 86 | endpoint="http://www.test-api.com/", 87 | headers={"Content-Type": "application/json", "Existing": "123"}, 88 | ) 89 | query = "" 90 | client.execute(query=query, headers={"Existing": "456", "New": "foo"}) 91 | 92 | post_mock.assert_called_once_with( 93 | "http://www.test-api.com/", 94 | json={"query": query}, 95 | headers={ 96 | "Content-Type": "application/json", 97 | "Existing": "456", 98 | "New": "foo", 99 | }, 100 | ) 101 | 102 | @patch("python_graphql_client.graphql_client.requests.post") 103 | def test_execute_query_with_options(self, post_mock): 104 | """Sends a graphql POST request with headers.""" 105 | auth = HTTPBasicAuth("fake@example.com", "not_a_real_password") 106 | client = GraphqlClient( 107 | endpoint="http://www.test-api.com/", 108 | auth=auth, 109 | ) 110 | query = "" 111 | client.execute(query=query, verify=False) 112 | 113 | post_mock.assert_called_once_with( 114 | "http://www.test-api.com/", 115 | json={"query": query}, 116 | headers={}, 117 | auth=HTTPBasicAuth("fake@example.com", "not_a_real_password"), 118 | verify=False, 119 | ) 120 | 121 | @patch("python_graphql_client.graphql_client.requests.post") 122 | def test_execute_query_with_operation_name(self, post_mock): 123 | """Sends a graphql POST request with the operationName key set.""" 124 | client = GraphqlClient(endpoint="http://www.test-api.com/") 125 | query = """ 126 | query firstQuery { 127 | test { 128 | status 129 | } 130 | } 131 | 132 | query secondQuery { 133 | test { 134 | status 135 | } 136 | } 137 | """ 138 | operation_name = "firstQuery" 139 | client.execute(query, operation_name=operation_name) 140 | 141 | post_mock.assert_called_once_with( 142 | "http://www.test-api.com/", 143 | json={"query": query, "operationName": operation_name}, 144 | headers={}, 145 | ) 146 | 147 | 148 | class TestGraphqlClientExecuteAsync(IsolatedAsyncioTestCase): 149 | """Test cases for the asynchronous graphQL request function.""" 150 | 151 | async def get_application(self): 152 | """Override base class method to properly use async tests.""" 153 | return web.Application() 154 | 155 | @patch("aiohttp.ClientSession.post") 156 | async def test_execute_basic_query(self, mock_post): 157 | """Sends a graphql POST request to an endpoint.""" 158 | mock_post.return_value.__aenter__.return_value.json = AsyncMock() 159 | client = GraphqlClient(endpoint="http://www.test-api.com/") 160 | query = """ 161 | { 162 | tests { 163 | status 164 | } 165 | } 166 | """ 167 | 168 | await client.execute_async(query) 169 | 170 | mock_post.assert_called_once_with( 171 | "http://www.test-api.com/", json={"query": query}, headers={} 172 | ) 173 | 174 | @patch("aiohttp.ClientSession.post") 175 | async def test_execute_basic_query_with_aiohttp_parameters(self, mock_post): 176 | """Sends a graphql POST request to an endpoint.""" 177 | mock_post.return_value.__aenter__.return_value.json = AsyncMock() 178 | client = GraphqlClient(endpoint="http://www.test-api.com/") 179 | query = """ 180 | { 181 | tests { 182 | status 183 | } 184 | } 185 | """ 186 | 187 | await client.execute_async( 188 | query, 189 | timeout=10, 190 | verify_ssl=False, 191 | headers={"Authorization": "Bearer token"}, 192 | ) 193 | 194 | mock_post.assert_called_once_with( 195 | "http://www.test-api.com/", 196 | json={"query": query}, 197 | headers={"Authorization": "Bearer token"}, 198 | timeout=10, 199 | verify_ssl=False, 200 | ) 201 | 202 | @patch("aiohttp.ClientSession.post") 203 | async def test_execute_query_with_variables(self, mock_post): 204 | """Sends a graphql POST request with variables.""" 205 | mock_post.return_value.__aenter__.return_value.json = AsyncMock() 206 | client = GraphqlClient(endpoint="http://www.test-api.com/") 207 | query = "" 208 | variables = {"id": 123} 209 | 210 | await client.execute_async(query, variables) 211 | 212 | mock_post.assert_called_once_with( 213 | "http://www.test-api.com/", 214 | json={"query": query, "variables": variables}, 215 | headers={}, 216 | ) 217 | 218 | @patch("aiohttp.ClientSession.post") 219 | async def test_execute_query_with_headers(self, mock_post): 220 | """Sends a graphql POST request with headers.""" 221 | mock_post.return_value.__aenter__.return_value.json = AsyncMock() 222 | client = GraphqlClient( 223 | endpoint="http://www.test-api.com/", 224 | headers={"Content-Type": "application/json", "Existing": "123"}, 225 | ) 226 | query = "" 227 | 228 | await client.execute_async("", headers={"Existing": "456", "New": "foo"}) 229 | 230 | mock_post.assert_called_once_with( 231 | "http://www.test-api.com/", 232 | json={"query": query}, 233 | headers={ 234 | "Content-Type": "application/json", 235 | "Existing": "456", 236 | "New": "foo", 237 | }, 238 | ) 239 | 240 | @patch("aiohttp.ClientSession.post") 241 | async def test_execute_query_with_operation_name(self, mock_post): 242 | """Sends a graphql POST request with the operationName key set.""" 243 | mock_post.return_value.__aenter__.return_value.json = AsyncMock() 244 | client = GraphqlClient(endpoint="http://www.test-api.com/") 245 | query = """ 246 | query firstQuery { 247 | test { 248 | status 249 | } 250 | } 251 | 252 | query secondQuery { 253 | test { 254 | status 255 | } 256 | } 257 | """ 258 | operation_name = "firstQuery" 259 | 260 | await client.execute_async(query, operation_name=operation_name) 261 | 262 | mock_post.assert_called_once_with( 263 | "http://www.test-api.com/", 264 | json={"query": query, "operationName": operation_name}, 265 | headers={}, 266 | ) 267 | 268 | 269 | class TestGraphqlClientSubscriptions(IsolatedAsyncioTestCase): 270 | """Test cases for subscribing GraphQL subscriptions.""" 271 | 272 | @patch("websockets.connect") 273 | async def test_subscribe(self, mock_connect): 274 | """Subsribe a GraphQL subscription.""" 275 | mock_websocket = mock_connect.return_value.__aenter__.return_value 276 | mock_websocket.send = AsyncMock() 277 | mock_websocket.__aiter__.return_value = [ 278 | '{"type": "data", "id": "1", "payload": {"data": {"messageAdded": "one"}}}', 279 | '{"type": "data", "id": "1", "payload": {"data": {"messageAdded": "two"}}}', 280 | ] 281 | 282 | client = GraphqlClient(endpoint="ws://www.test-api.com/graphql") 283 | query = """ 284 | subscription onMessageAdded { 285 | messageAdded 286 | } 287 | """ 288 | 289 | mock_handle = MagicMock() 290 | 291 | await client.subscribe(query=query, handle=mock_handle) 292 | 293 | mock_handle.assert_has_calls( 294 | [ 295 | call({"data": {"messageAdded": "one"}}), 296 | call({"data": {"messageAdded": "two"}}), 297 | ] 298 | ) 299 | 300 | @patch("logging.getLogger") 301 | @patch("websockets.connect") 302 | async def test_does_not_crash_with_keep_alive(self, mock_connect, mock_get_logger): 303 | """Subsribe a GraphQL subscription.""" 304 | mock_websocket = mock_connect.return_value.__aenter__.return_value 305 | mock_websocket.send = AsyncMock() 306 | mock_websocket.__aiter__.return_value = [ 307 | '{"type": "ka"}', 308 | ] 309 | 310 | client = GraphqlClient(endpoint="ws://www.test-api.com/graphql") 311 | query = """ 312 | subscription onMessageAdded { 313 | messageAdded 314 | } 315 | """ 316 | 317 | await client.subscribe(query=query, handle=MagicMock()) 318 | 319 | mock_get_logger.return_value.info.assert_has_calls( 320 | [call("the server sent a keep alive message")] 321 | ) 322 | 323 | @patch("websockets.connect") 324 | async def test_headers_passed_to_websocket_connect(self, mock_connect): 325 | """Subsribe a GraphQL subscription.""" 326 | mock_websocket = mock_connect.return_value.__aenter__.return_value 327 | mock_websocket.send = AsyncMock() 328 | mock_websocket.__aiter__.return_value = [ 329 | '{"type": "data", "id": "1", "payload": {"data": {"messageAdded": "one"}}}', 330 | ] 331 | 332 | expected_endpoint = "ws://www.test-api.com/graphql" 333 | client = GraphqlClient(endpoint=expected_endpoint) 334 | 335 | query = """ 336 | subscription onMessageAdded { 337 | messageAdded 338 | } 339 | """ 340 | 341 | mock_handle = MagicMock() 342 | 343 | expected_headers = {"some": "header"} 344 | 345 | await client.subscribe( 346 | query=query, handle=mock_handle, headers=expected_headers 347 | ) 348 | 349 | mock_connect.assert_called_with( 350 | expected_endpoint, 351 | subprotocols=["graphql-ws"], 352 | extra_headers=expected_headers, 353 | ) 354 | 355 | mock_handle.assert_has_calls([call({"data": {"messageAdded": "one"}})]) 356 | 357 | @patch("websockets.connect") 358 | async def test_init_payload_passed_in_init_message(self, mock_connect): 359 | """Subsribe a GraphQL subscription.""" 360 | mock_websocket = mock_connect.return_value.__aenter__.return_value 361 | mock_websocket.send = AsyncMock() 362 | mock_websocket.__aiter__.return_value = [ 363 | '{"type": "connection_init", "payload": ' 364 | '{"init": "this is the init_payload"}}', 365 | '{"type": "data", "id": "1", "payload": {"data": {"messageAdded": "one"}}}', 366 | ] 367 | expected_endpoint = "ws://www.test-api.com/graphql" 368 | client = GraphqlClient(endpoint=expected_endpoint) 369 | 370 | query = """ 371 | subscription onMessageAdded { 372 | messageAdded 373 | } 374 | """ 375 | init_payload = '{"init": "this is the init_payload"}' 376 | 377 | mock_handle = MagicMock() 378 | 379 | await client.subscribe( 380 | query=query, handle=mock_handle, init_payload=init_payload 381 | ) 382 | 383 | mock_connect.assert_called_with( 384 | expected_endpoint, subprotocols=["graphql-ws"], extra_headers={} 385 | ) 386 | 387 | mock_handle.assert_has_calls( 388 | [ 389 | call({"init": "this is the init_payload"}), 390 | call({"data": {"messageAdded": "one"}}), 391 | ] 392 | ) 393 | --------------------------------------------------------------------------------