├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── aiohttp_graphql └── __init__.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── app.py ├── schema.py ├── test_graphiqlview.py └── test_graphqlview.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/python,intellij+all,visualstudiocode 3 | # Edit at https://www.gitignore.io/?templates=python,intellij+all,visualstudiocode 4 | 5 | ### Intellij+all ### 6 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 7 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 8 | 9 | # User-specific stuff 10 | .idea/**/workspace.xml 11 | .idea/**/tasks.xml 12 | .idea/**/usage.statistics.xml 13 | .idea/**/dictionaries 14 | .idea/**/shelf 15 | 16 | # Generated files 17 | .idea/**/contentModel.xml 18 | 19 | # Sensitive or high-churn files 20 | .idea/**/dataSources/ 21 | .idea/**/dataSources.ids 22 | .idea/**/dataSources.local.xml 23 | .idea/**/sqlDataSources.xml 24 | .idea/**/dynamic.xml 25 | .idea/**/uiDesigner.xml 26 | .idea/**/dbnavigator.xml 27 | 28 | # Gradle 29 | .idea/**/gradle.xml 30 | .idea/**/libraries 31 | 32 | # Gradle and Maven with auto-import 33 | # When using Gradle or Maven with auto-import, you should exclude module files, 34 | # since they will be recreated, and may cause churn. Uncomment if using 35 | # auto-import. 36 | # .idea/modules.xml 37 | # .idea/*.iml 38 | # .idea/modules 39 | # *.iml 40 | # *.ipr 41 | 42 | # CMake 43 | cmake-build-*/ 44 | 45 | # Mongo Explorer plugin 46 | .idea/**/mongoSettings.xml 47 | 48 | # File-based project format 49 | *.iws 50 | 51 | # IntelliJ 52 | out/ 53 | 54 | # mpeltonen/sbt-idea plugin 55 | .idea_modules/ 56 | 57 | # JIRA plugin 58 | atlassian-ide-plugin.xml 59 | 60 | # Cursive Clojure plugin 61 | .idea/replstate.xml 62 | 63 | # Crashlytics plugin (for Android Studio and IntelliJ) 64 | com_crashlytics_export_strings.xml 65 | crashlytics.properties 66 | crashlytics-build.properties 67 | fabric.properties 68 | 69 | # Editor-based Rest Client 70 | .idea/httpRequests 71 | 72 | # Android studio 3.1+ serialized cache file 73 | .idea/caches/build_file_checksums.ser 74 | 75 | ### Intellij+all Patch ### 76 | # Ignores the whole .idea folder and all .iml files 77 | # See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 78 | 79 | .idea/ 80 | 81 | # Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 82 | 83 | *.iml 84 | modules.xml 85 | .idea/misc.xml 86 | *.ipr 87 | 88 | # Sonarlint plugin 89 | .idea/sonarlint 90 | 91 | ### Python ### 92 | # Byte-compiled / optimized / DLL files 93 | __pycache__/ 94 | *.py[cod] 95 | *$py.class 96 | 97 | # C extensions 98 | *.so 99 | 100 | # Distribution / packaging 101 | .Python 102 | build/ 103 | develop-eggs/ 104 | dist/ 105 | downloads/ 106 | eggs/ 107 | .eggs/ 108 | lib/ 109 | lib64/ 110 | parts/ 111 | sdist/ 112 | var/ 113 | wheels/ 114 | pip-wheel-metadata/ 115 | share/python-wheels/ 116 | *.egg-info/ 117 | .installed.cfg 118 | *.egg 119 | MANIFEST 120 | 121 | # PyInstaller 122 | # Usually these files are written by a python script from a template 123 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 124 | *.manifest 125 | *.spec 126 | 127 | # Installer logs 128 | pip-log.txt 129 | pip-delete-this-directory.txt 130 | 131 | # Unit test / coverage reports 132 | htmlcov/ 133 | .tox/ 134 | .nox/ 135 | .venv/ 136 | .coverage 137 | .coverage.* 138 | .cache 139 | nosetests.xml 140 | coverage.xml 141 | *.cover 142 | .hypothesis/ 143 | .pytest_cache/ 144 | 145 | # Translations 146 | *.mo 147 | *.pot 148 | 149 | # Scrapy stuff: 150 | .scrapy 151 | 152 | # Sphinx documentation 153 | docs/_build/ 154 | 155 | # PyBuilder 156 | target/ 157 | 158 | # pyenv 159 | .python-version 160 | 161 | # pipenv 162 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 163 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 164 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 165 | # install all needed dependencies. 166 | #Pipfile.lock 167 | 168 | # celery beat schedule file 169 | celerybeat-schedule 170 | 171 | # SageMath parsed files 172 | *.sage.py 173 | 174 | # Spyder project settings 175 | .spyderproject 176 | .spyproject 177 | 178 | # Rope project settings 179 | .ropeproject 180 | 181 | # Mr Developer 182 | .mr.developer.cfg 183 | .project 184 | .pydevproject 185 | 186 | # mkdocs documentation 187 | /site 188 | 189 | # mypy 190 | .mypy_cache/ 191 | .dmypy.json 192 | dmypy.json 193 | 194 | # Pyre type checker 195 | .pyre/ 196 | 197 | ### VisualStudioCode ### 198 | .vscode 199 | 200 | ### VisualStudioCode Patch ### 201 | # Ignore all local history of files 202 | .history 203 | 204 | # End of https://www.gitignore.io/api/python,intellij+all,visualstudiocode 205 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: false 3 | python: 4 | - 3.6 5 | - 3.7 6 | - 3.8 7 | cache: pip 8 | 9 | install: 10 | - pip install tox-travis 11 | 12 | script: 13 | - tox 14 | 15 | after_success: 16 | - codecov 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Devin Fee 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. 22 | 23 | Copyright for portions of project aiohttp-graphql are held by Syrus Akbary, 2015 24 | as part of project flask-graphql, and Sergey Privaev, as part of project 25 | sanic-graphql. All other copyright for project aiohttp-graphql are held by Devin 26 | Fee. 27 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include MANIFEST.in 2 | 3 | include LICENSE 4 | include README.md 5 | 6 | include tox.ini 7 | include Makefile 8 | 9 | graft aiohttp_graphql 10 | graft tests 11 | 12 | global-exclude *.py[co] __pycache__ 13 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | dev-setup: 2 | python pip install -e ".[test]" 3 | 4 | tests: 5 | py.test tests --cov=aiohttp_graphql -vv -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aiohttp-graphql 2 | Adds [GraphQL] support to your [aiohttp] application. 3 | 4 | Based on [flask-graphql] by [Syrus Akbary] and [sanic-graphql] by [Sergey Porivaev]. 5 | 6 | [![PyPI version](https://badge.fury.io/py/aiohttp-graphql.svg)](https://badge.fury.io/py/aiohttp-graphql) 7 | [![Build Status](https://travis-ci.com/graphql-python/aiohttp-graphql.svg?branch=master)](https://github.com/graphql-python/aiohttp-graphql) 8 | [![Coverage Status](https://codecov.io/gh/graphql-python/aiohttp-graphql/branch/master/graph/badge.svg)](https://github.com/graphql-python/aiohttp-graphql) 9 | 10 | ## Usage 11 | 12 | Use the `GraphQLView` view from `aiohttp_graphql` 13 | 14 | ```python 15 | from aiohttp import web 16 | from aiohttp_graphql import GraphQLView 17 | 18 | from schema import schema 19 | 20 | app = web.Application() 21 | 22 | GraphQLView.attach(app, schema=schema, graphiql=True) 23 | 24 | # Optional, for adding batch query support (used in Apollo-Client) 25 | GraphQLView.attach(app, schema=schema, batch=True, route_path="/graphql/batch") 26 | 27 | if __name__ == '__main__': 28 | web.run_app(app) 29 | ``` 30 | 31 | This will add `/graphql` endpoint to your app (customizable by passing `route_path='/mypath'` to `GraphQLView.attach`) and enable the GraphiQL IDE. 32 | 33 | Note: `GraphQLView.attach` is just a convenience function, and the same functionality can be achieved with 34 | 35 | ```python 36 | gql_view = GraphQLView(schema=schema, graphiql=True) 37 | app.router.add_route('*', '/graphql', gql_view, name='graphql') 38 | ``` 39 | 40 | It's worth noting that the the "view function" of `GraphQLView` is contained in `GraphQLView.__call__`. So, when you create an instance, that instance is callable with the request object as the sole positional argument. To illustrate: 41 | 42 | ```python 43 | gql_view = GraphQLView(schema=Schema, **kwargs) 44 | gql_view(request) # <-- the instance is callable and expects a `aiohttp.web.Request` object. 45 | ``` 46 | 47 | ### Supported options for GraphQLView 48 | 49 | * `schema`: The `GraphQLSchema` object that you want the view to execute when it gets a valid request. 50 | * `context`: A value to pass as the `context_value` to graphql `execute` function. By default is set to `dict` with request object at key `request`. 51 | * `root_value`: The `root_value` you want to provide to graphql `execute`. 52 | * `pretty`: Whether or not you want the response to be pretty printed JSON. 53 | * `graphiql`: If `True`, may present [GraphiQL](https://github.com/graphql/graphiql) when loaded directly from a browser (a useful tool for debugging and exploration). 54 | * `graphiql_version`: The graphiql version to load. Defaults to **"1.0.3"**. 55 | * `graphiql_template`: Inject a Jinja template string to customize GraphiQL. 56 | * `graphiql_html_title`: The graphiql title to display. Defaults to **"GraphiQL"**. 57 | * `jinja_env`: Sets jinja environment to be used to process GraphiQL template. If Jinja’s async mode is enabled (by `enable_async=True`), uses 58 | `Template.render_async` instead of `Template.render`. If environment is not set, fallbacks to simple regex-based renderer. 59 | * `batch`: Set the GraphQL view as batch (for using in [Apollo-Client](http://dev.apollodata.com/core/network.html#query-batching) or [ReactRelayNetworkLayer](https://github.com/nodkz/react-relay-network-layer)) 60 | * `middleware`: A list of graphql [middlewares](http://docs.graphene-python.org/en/latest/execution/middleware/). 61 | * `max_age`: Sets the response header Access-Control-Max-Age for preflight requests. 62 | * `encode`: the encoder to use for responses (sensibly defaults to `graphql_server.json_encode`). 63 | * `format_error`: the error formatter to use for responses (sensibly defaults to `graphql_server.default_format_error`. 64 | * `enable_async`: whether `async` mode will be enabled. 65 | * `subscriptions`: The GraphiQL socket endpoint for using subscriptions in graphql-ws. 66 | * `headers`: An optional GraphQL string to use as the initial displayed request headers, if not provided, the stored headers will be used. 67 | * `default_query`: An optional GraphQL string to use when no query is provided and no stored query exists from a previous session. If not provided, GraphiQL will use its own default query. 68 | * `header_editor_enabled`: An optional boolean which enables the header editor when true. Defaults to **false**. 69 | * `should_persist_headers`: An optional boolean which enables to persist headers to storage when true. Defaults to **false**. 70 | 71 | 72 | ## Contributing 73 | Since v3, `aiohttp-graphql` code lives at [graphql-server](https://github.com/graphql-python/graphql-server) repository to keep any breaking change on the base package on sync with all other integrations. In order to contribute, please take a look at [CONTRIBUTING.md](https://github.com/graphql-python/graphql-server/blob/master/CONTRIBUTING.md). 74 | 75 | 76 | ## License 77 | Copyright for portions of project [aiohttp-graphql] are held by [Syrus Akbary] as part of project [flask-graphql] and [sanic-graphql] as part of project [Sergey Porivaev]. All other claims to this project [aiohttp-graphql] are held by [Devin Fee]. 78 | 79 | This project is licensed under the MIT License. 80 | 81 | [GraphQL]: http://graphql.org/ 82 | [aiohttp]: https://github.com/aio-libs/aiohttp/ 83 | [flask-graphql]: https://github.com/graphql-python/flask-graphql 84 | [sanic-graphql]: https://github.com/graphql-python/sanic-graphql 85 | [Syrus Akbary]: https://github.com/syrusakbary 86 | [Sergey Porivaev]: https://github.com/grazor 87 | [GraphiQL]: https://github.com/graphql/graphiql 88 | [graphql-python]: https://github.com/graphql-python/graphql-core 89 | [Apollo-Client]: https://www.apollographql.com/docs/react/networking/network-layer/#query-batching 90 | [Devin Fee]: https://github.com/dfee 91 | [aiohttp-graphql]: https://github.com/graphql-python/aiohttp-graphql 92 | [graphql-ws]: https://github.com/graphql-python/graphql-ws 93 | -------------------------------------------------------------------------------- /aiohttp_graphql/__init__.py: -------------------------------------------------------------------------------- 1 | from graphql_server.aiohttp.graphqlview import GraphQLView 2 | 3 | __all__ = ["GraphQLView"] 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = tests,scripts,setup.py,docs 3 | max-line-length = 88 4 | 5 | [isort] 6 | known_first_party=graphql 7 | 8 | [tool:pytest] 9 | norecursedirs = venv .venv .tox .git .cache .mypy_cache .pytest_cache 10 | markers = asyncio 11 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | install_requires = [ 4 | "graphql-server[aiohttp]>=3.0.0b1", 5 | ] 6 | 7 | tests_requires = [ 8 | "pytest>=5.4,<5.5", 9 | "pytest-asyncio>=0.11.0", 10 | "pytest-cov>=2.8,<3", 11 | "Jinja2>=2.10.1,<3", 12 | ] 13 | 14 | dev_requires = [ 15 | "flake8>=3.7,<4", 16 | "isort>=4,<5", 17 | "check-manifest>=0.40,<1", 18 | ] + tests_requires 19 | 20 | with open("README.md", encoding="utf-8") as readme_file: 21 | readme = readme_file.read() 22 | 23 | setup( 24 | name="aiohttp-graphql", 25 | version="1.1.0", 26 | description="Adds GraphQL support to your aiohttp application", 27 | long_description=readme, 28 | long_description_content_type="text/markdown", 29 | url="https://github.com/graphql-python/aiohttp-graphql", 30 | download_url="https://github.com/graphql-python/aiohttp-graphql/releases", 31 | author="Devin Fee", 32 | author_email="devin@devinfee.com", 33 | license="MIT", 34 | classifiers=[ 35 | "Development Status :: 5 - Production/Stable", 36 | "Intended Audience :: Developers", 37 | "Topic :: Software Development :: Libraries", 38 | "Programming Language :: Python :: 3.6", 39 | "Programming Language :: Python :: 3.7", 40 | "Programming Language :: Python :: 3.8", 41 | "License :: OSI Approved :: MIT License", 42 | ], 43 | keywords="api graphql protocol aiohttp", 44 | packages=find_packages(exclude=["tests"]), 45 | install_requires=install_requires, 46 | tests_require=tests_requires, 47 | extras_require={ 48 | 'test': tests_requires, 49 | 'dev': dev_requires, 50 | }, 51 | include_package_data=True, 52 | zip_safe=False, 53 | platforms="any", 54 | ) 55 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/graphql-python/aiohttp-graphql/5f7580310761dd7de33b44bc92f30b2695f2d523/tests/__init__.py -------------------------------------------------------------------------------- /tests/app.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlencode 2 | 3 | from aiohttp import web 4 | 5 | from aiohttp_graphql import GraphQLView 6 | from tests.schema import Schema 7 | 8 | 9 | def create_app(schema=Schema, **kwargs): 10 | app = web.Application() 11 | # Only needed to silence aiohttp deprecation warnings 12 | GraphQLView.attach(app, schema=schema, **kwargs) 13 | return app 14 | 15 | 16 | def url_string(**url_params): 17 | base_url = "/graphql" 18 | 19 | if url_params: 20 | return f"{base_url}?{urlencode(url_params)}" 21 | 22 | return base_url 23 | -------------------------------------------------------------------------------- /tests/schema.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from graphql.type.definition import (GraphQLArgument, GraphQLField, 4 | GraphQLNonNull, GraphQLObjectType) 5 | from graphql.type.scalars import GraphQLString 6 | from graphql.type.schema import GraphQLSchema 7 | 8 | 9 | def resolve_raises(*_): 10 | raise Exception("Throws!") 11 | 12 | 13 | # Sync schema 14 | QueryRootType = GraphQLObjectType( 15 | name="QueryRoot", 16 | fields={ 17 | "thrower": GraphQLField(GraphQLNonNull(GraphQLString), resolve=resolve_raises,), 18 | "request": GraphQLField( 19 | GraphQLNonNull(GraphQLString), 20 | resolve=lambda obj, info, *args: info.context["request"].query.get("q"), 21 | ), 22 | "context": GraphQLField( 23 | GraphQLObjectType( 24 | name="context", 25 | fields={ 26 | "session": GraphQLField(GraphQLString), 27 | "request": GraphQLField( 28 | GraphQLNonNull(GraphQLString), 29 | resolve=lambda obj, info: info.context["request"], 30 | ), 31 | }, 32 | ), 33 | resolve=lambda obj, info: info.context, 34 | ), 35 | "test": GraphQLField( 36 | type_=GraphQLString, 37 | args={"who": GraphQLArgument(GraphQLString)}, 38 | resolve=lambda obj, info, who=None: "Hello %s" % (who or "World"), 39 | ), 40 | }, 41 | ) 42 | 43 | 44 | MutationRootType = GraphQLObjectType( 45 | name="MutationRoot", 46 | fields={ 47 | "writeTest": GraphQLField( 48 | type_=QueryRootType, resolve=lambda *args: QueryRootType 49 | ) 50 | }, 51 | ) 52 | 53 | SubscriptionsRootType = GraphQLObjectType( 54 | name="SubscriptionsRoot", 55 | fields={ 56 | "subscriptionsTest": GraphQLField( 57 | type_=QueryRootType, resolve=lambda *args: QueryRootType 58 | ) 59 | }, 60 | ) 61 | 62 | Schema = GraphQLSchema(QueryRootType, MutationRootType, SubscriptionsRootType) 63 | 64 | 65 | # Schema with async methods 66 | async def resolver_field_async_1(_obj, info): 67 | await asyncio.sleep(0.001) 68 | return "hey" 69 | 70 | 71 | async def resolver_field_async_2(_obj, info): 72 | await asyncio.sleep(0.003) 73 | return "hey2" 74 | 75 | 76 | def resolver_field_sync(_obj, info): 77 | return "hey3" 78 | 79 | 80 | AsyncQueryType = GraphQLObjectType( 81 | "AsyncQueryType", 82 | { 83 | "a": GraphQLField(GraphQLString, resolve=resolver_field_async_1), 84 | "b": GraphQLField(GraphQLString, resolve=resolver_field_async_2), 85 | "c": GraphQLField(GraphQLString, resolve=resolver_field_sync), 86 | }, 87 | ) 88 | 89 | 90 | AsyncSchema = GraphQLSchema(AsyncQueryType) 91 | -------------------------------------------------------------------------------- /tests/test_graphiqlview.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from aiohttp.test_utils import TestClient, TestServer 3 | from jinja2 import Environment 4 | 5 | from tests.app import create_app, url_string 6 | from tests.schema import AsyncSchema, Schema 7 | 8 | 9 | @pytest.fixture 10 | def app(): 11 | app = create_app() 12 | return app 13 | 14 | 15 | @pytest.fixture 16 | async def client(app): 17 | client = TestClient(TestServer(app)) 18 | await client.start_server() 19 | yield client 20 | await client.close() 21 | 22 | 23 | @pytest.fixture 24 | def view_kwargs(): 25 | return { 26 | "schema": Schema, 27 | "graphiql": True, 28 | } 29 | 30 | 31 | @pytest.fixture 32 | def pretty_response(): 33 | return ( 34 | "{\n" 35 | ' "data": {\n' 36 | ' "test": "Hello World"\n' 37 | " }\n" 38 | "}".replace('"', '\\"').replace("\n", "\\n") 39 | ) 40 | 41 | 42 | @pytest.mark.asyncio 43 | @pytest.mark.parametrize("app", [create_app(graphiql=True)]) 44 | async def test_graphiql_is_enabled(app, client): 45 | response = await client.get( 46 | url_string(query="{test}"), headers={"Accept": "text/html"} 47 | ) 48 | assert response.status == 200 49 | 50 | 51 | @pytest.mark.asyncio 52 | @pytest.mark.parametrize("app", [create_app(graphiql=True)]) 53 | async def test_graphiql_simple_renderer(app, client, pretty_response): 54 | response = await client.get( 55 | url_string(query="{test}"), headers={"Accept": "text/html"}, 56 | ) 57 | assert response.status == 200 58 | assert pretty_response in await response.text() 59 | 60 | 61 | class TestJinjaEnv: 62 | @pytest.mark.asyncio 63 | @pytest.mark.parametrize( 64 | "app", [create_app(graphiql=True, jinja_env=Environment(enable_async=True))] 65 | ) 66 | async def test_graphiql_jinja_renderer_async(self, app, client, pretty_response): 67 | response = await client.get( 68 | url_string(query="{test}"), headers={"Accept": "text/html"}, 69 | ) 70 | assert response.status == 200 71 | assert pretty_response in await response.text() 72 | 73 | 74 | @pytest.mark.asyncio 75 | async def test_graphiql_html_is_not_accepted(client): 76 | response = await client.get("/graphql", headers={"Accept": "application/json"},) 77 | assert response.status == 400 78 | 79 | 80 | @pytest.mark.asyncio 81 | @pytest.mark.parametrize("app", [create_app(graphiql=True)]) 82 | async def test_graphiql_get_mutation(app, client): 83 | response = await client.get( 84 | url_string(query="mutation TestMutation { writeTest { test } }"), 85 | headers={"Accept": "text/html"}, 86 | ) 87 | assert response.status == 200 88 | assert "response: null" in await response.text() 89 | 90 | 91 | @pytest.mark.asyncio 92 | @pytest.mark.parametrize("app", [create_app(graphiql=True)]) 93 | async def test_graphiql_get_subscriptions(app, client): 94 | response = await client.get( 95 | url_string( 96 | query="subscription TestSubscriptions { subscriptionsTest { test } }" 97 | ), 98 | headers={"Accept": "text/html"}, 99 | ) 100 | assert response.status == 200 101 | assert "response: null" in await response.text() 102 | 103 | 104 | @pytest.mark.asyncio 105 | @pytest.mark.parametrize("app", [create_app(schema=AsyncSchema, enable_async=True)]) 106 | async def test_graphiql_async_schema(app, client): 107 | response = await client.get( 108 | url_string(query="{a,b,c}"), headers={"Accept": "text/html"}, 109 | ) 110 | 111 | assert response.status == 200 112 | assert await response.json() == {"data": {"a": "hey", "b": "hey2", "c": "hey3"}} 113 | -------------------------------------------------------------------------------- /tests/test_graphqlview.py: -------------------------------------------------------------------------------- 1 | import json 2 | from urllib.parse import urlencode 3 | 4 | import pytest 5 | from aiohttp import FormData 6 | from aiohttp.test_utils import TestClient, TestServer 7 | 8 | from .app import create_app, url_string 9 | from .schema import AsyncSchema 10 | 11 | 12 | @pytest.fixture 13 | def app(): 14 | app = create_app() 15 | return app 16 | 17 | 18 | @pytest.fixture 19 | async def client(app): 20 | client = TestClient(TestServer(app)) 21 | await client.start_server() 22 | yield client 23 | await client.close() 24 | 25 | 26 | @pytest.mark.asyncio 27 | async def test_allows_get_with_query_param(client): 28 | response = await client.get(url_string(query="{test}")) 29 | 30 | assert response.status == 200 31 | assert await response.json() == {"data": {"test": "Hello World"}} 32 | 33 | 34 | @pytest.mark.asyncio 35 | async def test_allows_get_with_variable_values(client): 36 | response = await client.get( 37 | url_string( 38 | query="query helloWho($who: String) { test(who: $who) }", 39 | variables=json.dumps({"who": "Dolly"}), 40 | ) 41 | ) 42 | 43 | assert response.status == 200 44 | assert await response.json() == {"data": {"test": "Hello Dolly"}} 45 | 46 | 47 | @pytest.mark.asyncio 48 | async def test_allows_get_with_operation_name(client): 49 | response = await client.get( 50 | url_string( 51 | query=""" 52 | query helloYou { test(who: "You"), ...shared } 53 | query helloWorld { test(who: "World"), ...shared } 54 | query helloDolly { test(who: "Dolly"), ...shared } 55 | fragment shared on QueryRoot { 56 | shared: test(who: "Everyone") 57 | } 58 | """, 59 | operationName="helloWorld", 60 | ) 61 | ) 62 | 63 | assert response.status == 200 64 | assert await response.json() == { 65 | "data": {"test": "Hello World", "shared": "Hello Everyone"} 66 | } 67 | 68 | 69 | @pytest.mark.asyncio 70 | async def test_reports_validation_errors(client): 71 | response = await client.get(url_string(query="{ test, unknownOne, unknownTwo }")) 72 | 73 | assert response.status == 400 74 | assert await response.json() == { 75 | "errors": [ 76 | { 77 | "message": "Cannot query field 'unknownOne' on type 'QueryRoot'.", 78 | "locations": [{"line": 1, "column": 9}], 79 | "path": None, 80 | }, 81 | { 82 | "message": "Cannot query field 'unknownTwo' on type 'QueryRoot'.", 83 | "locations": [{"line": 1, "column": 21}], 84 | "path": None, 85 | }, 86 | ], 87 | } 88 | 89 | 90 | @pytest.mark.asyncio 91 | async def test_errors_when_missing_operation_name(client): 92 | response = await client.get( 93 | url_string( 94 | query=""" 95 | query TestQuery { test } 96 | mutation TestMutation { writeTest { test } } 97 | subscription TestSubscriptions { subscriptionsTest { test } } 98 | """ 99 | ) 100 | ) 101 | 102 | assert response.status == 400 103 | assert await response.json() == { 104 | "errors": [ 105 | { 106 | "message": ( 107 | "Must provide operation name if query contains multiple " 108 | "operations." 109 | ), 110 | "locations": None, 111 | "path": None, 112 | }, 113 | ] 114 | } 115 | 116 | 117 | @pytest.mark.asyncio 118 | async def test_errors_when_sending_a_mutation_via_get(client): 119 | response = await client.get( 120 | url_string( 121 | query=""" 122 | mutation TestMutation { writeTest { test } } 123 | """ 124 | ) 125 | ) 126 | assert response.status == 405 127 | assert await response.json() == { 128 | "errors": [ 129 | { 130 | "message": "Can only perform a mutation operation from a POST request.", 131 | "locations": None, 132 | "path": None, 133 | }, 134 | ], 135 | } 136 | 137 | 138 | @pytest.mark.asyncio 139 | async def test_errors_when_selecting_a_mutation_within_a_get(client): 140 | response = await client.get( 141 | url_string( 142 | query=""" 143 | query TestQuery { test } 144 | mutation TestMutation { writeTest { test } } 145 | """, 146 | operationName="TestMutation", 147 | ) 148 | ) 149 | 150 | assert response.status == 405 151 | assert await response.json() == { 152 | "errors": [ 153 | { 154 | "message": "Can only perform a mutation operation from a POST request.", 155 | "locations": None, 156 | "path": None, 157 | }, 158 | ], 159 | } 160 | 161 | 162 | @pytest.mark.asyncio 163 | async def test_errors_when_selecting_a_subscription_within_a_get(client): 164 | response = await client.get( 165 | url_string( 166 | query=""" 167 | subscription TestSubscriptions { subscriptionsTest { test } } 168 | """, 169 | operationName="TestSubscriptions", 170 | ) 171 | ) 172 | 173 | assert response.status == 405 174 | assert await response.json() == { 175 | "errors": [ 176 | { 177 | "message": "Can only perform a subscription operation from a POST " 178 | "request.", 179 | "locations": None, 180 | "path": None, 181 | }, 182 | ], 183 | } 184 | 185 | 186 | @pytest.mark.asyncio 187 | async def test_allows_mutation_to_exist_within_a_get(client): 188 | response = await client.get( 189 | url_string( 190 | query=""" 191 | query TestQuery { test } 192 | mutation TestMutation { writeTest { test } } 193 | """, 194 | operationName="TestQuery", 195 | ) 196 | ) 197 | 198 | assert response.status == 200 199 | assert await response.json() == {"data": {"test": "Hello World"}} 200 | 201 | 202 | @pytest.mark.asyncio 203 | async def test_allows_post_with_json_encoding(client): 204 | response = await client.post( 205 | "/graphql", 206 | data=json.dumps(dict(query="{test}")), 207 | headers={"content-type": "application/json"}, 208 | ) 209 | 210 | assert await response.json() == {"data": {"test": "Hello World"}} 211 | assert response.status == 200 212 | 213 | 214 | @pytest.mark.asyncio 215 | async def test_allows_sending_a_mutation_via_post(client): 216 | response = await client.post( 217 | "/graphql", 218 | data=json.dumps(dict(query="mutation TestMutation { writeTest { test } }",)), 219 | headers={"content-type": "application/json"}, 220 | ) 221 | 222 | assert response.status == 200 223 | assert await response.json() == {"data": {"writeTest": {"test": "Hello World"}}} 224 | 225 | 226 | @pytest.mark.asyncio 227 | async def test_allows_post_with_url_encoding(client): 228 | data = FormData() 229 | data.add_field("query", "{test}") 230 | response = await client.post( 231 | "/graphql", 232 | data=data(), 233 | headers={"content-type": "application/x-www-form-urlencoded"}, 234 | ) 235 | 236 | assert await response.json() == {"data": {"test": "Hello World"}} 237 | assert response.status == 200 238 | 239 | 240 | @pytest.mark.asyncio 241 | async def test_supports_post_json_query_with_string_variables(client): 242 | response = await client.post( 243 | "/graphql", 244 | data=json.dumps( 245 | dict( 246 | query="query helloWho($who: String){ test(who: $who) }", 247 | variables=json.dumps({"who": "Dolly"}), 248 | ) 249 | ), 250 | headers={"content-type": "application/json"}, 251 | ) 252 | 253 | assert response.status == 200 254 | assert await response.json() == {"data": {"test": "Hello Dolly"}} 255 | 256 | 257 | @pytest.mark.asyncio 258 | async def test_supports_post_json_query_with_json_variables(client): 259 | response = await client.post( 260 | "/graphql", 261 | data=json.dumps( 262 | dict( 263 | query="query helloWho($who: String){ test(who: $who) }", 264 | variables={"who": "Dolly"}, 265 | ) 266 | ), 267 | headers={"content-type": "application/json"}, 268 | ) 269 | 270 | assert response.status == 200 271 | assert await response.json() == {"data": {"test": "Hello Dolly"}} 272 | 273 | 274 | @pytest.mark.asyncio 275 | async def test_supports_post_url_encoded_query_with_string_variables(client): 276 | response = await client.post( 277 | "/graphql", 278 | data=urlencode( 279 | dict( 280 | query="query helloWho($who: String){ test(who: $who) }", 281 | variables=json.dumps({"who": "Dolly"}), 282 | ), 283 | ), 284 | headers={"content-type": "application/x-www-form-urlencoded"}, 285 | ) 286 | 287 | assert response.status == 200 288 | assert await response.json() == {"data": {"test": "Hello Dolly"}} 289 | 290 | 291 | @pytest.mark.asyncio 292 | async def test_supports_post_json_quey_with_get_variable_values(client): 293 | response = await client.post( 294 | url_string(variables=json.dumps({"who": "Dolly"})), 295 | data=json.dumps(dict(query="query helloWho($who: String){ test(who: $who) }",)), 296 | headers={"content-type": "application/json"}, 297 | ) 298 | 299 | assert response.status == 200 300 | assert await response.json() == {"data": {"test": "Hello Dolly"}} 301 | 302 | 303 | @pytest.mark.asyncio 304 | async def test_post_url_encoded_query_with_get_variable_values(client): 305 | response = await client.post( 306 | url_string(variables=json.dumps({"who": "Dolly"})), 307 | data=urlencode(dict(query="query helloWho($who: String){ test(who: $who) }",)), 308 | headers={"content-type": "application/x-www-form-urlencoded"}, 309 | ) 310 | 311 | assert response.status == 200 312 | assert await response.json() == {"data": {"test": "Hello Dolly"}} 313 | 314 | 315 | @pytest.mark.asyncio 316 | async def test_supports_post_raw_text_query_with_get_variable_values(client): 317 | response = await client.post( 318 | url_string(variables=json.dumps({"who": "Dolly"})), 319 | data="query helloWho($who: String){ test(who: $who) }", 320 | headers={"content-type": "application/graphql"}, 321 | ) 322 | 323 | assert response.status == 200 324 | assert await response.json() == {"data": {"test": "Hello Dolly"}} 325 | 326 | 327 | @pytest.mark.asyncio 328 | async def test_allows_post_with_operation_name(client): 329 | response = await client.post( 330 | "/graphql", 331 | data=json.dumps( 332 | dict( 333 | query=""" 334 | query helloYou { test(who: "You"), ...shared } 335 | query helloWorld { test(who: "World"), ...shared } 336 | query helloDolly { test(who: "Dolly"), ...shared } 337 | fragment shared on QueryRoot { 338 | shared: test(who: "Everyone") 339 | } 340 | """, 341 | operationName="helloWorld", 342 | ) 343 | ), 344 | headers={"content-type": "application/json"}, 345 | ) 346 | 347 | assert response.status == 200 348 | assert await response.json() == { 349 | "data": {"test": "Hello World", "shared": "Hello Everyone"} 350 | } 351 | 352 | 353 | @pytest.mark.asyncio 354 | async def test_allows_post_with_get_operation_name(client): 355 | response = await client.post( 356 | url_string(operationName="helloWorld"), 357 | data=""" 358 | query helloYou { test(who: "You"), ...shared } 359 | query helloWorld { test(who: "World"), ...shared } 360 | query helloDolly { test(who: "Dolly"), ...shared } 361 | fragment shared on QueryRoot { 362 | shared: test(who: "Everyone") 363 | } 364 | """, 365 | headers={"content-type": "application/graphql"}, 366 | ) 367 | 368 | assert response.status == 200 369 | assert await response.json() == { 370 | "data": {"test": "Hello World", "shared": "Hello Everyone"} 371 | } 372 | 373 | 374 | @pytest.mark.asyncio 375 | async def test_supports_pretty_printing(client): 376 | response = await client.get(url_string(query="{test}", pretty="1")) 377 | 378 | text = await response.text() 379 | assert text == "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" 380 | 381 | 382 | @pytest.mark.asyncio 383 | async def test_not_pretty_by_default(client): 384 | response = await client.get(url_string(query="{test}")) 385 | 386 | assert await response.text() == '{"data":{"test":"Hello World"}}' 387 | 388 | 389 | @pytest.mark.asyncio 390 | async def test_supports_pretty_printing_by_request(client): 391 | response = await client.get(url_string(query="{test}", pretty="1")) 392 | 393 | assert await response.text() == ( 394 | "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" 395 | ) 396 | 397 | 398 | @pytest.mark.asyncio 399 | async def test_handles_field_errors_caught_by_graphql(client): 400 | response = await client.get(url_string(query="{thrower}")) 401 | assert response.status == 200 402 | assert await response.json() == { 403 | "data": None, 404 | "errors": [ 405 | { 406 | "locations": [{"column": 2, "line": 1}], 407 | "message": "Throws!", 408 | "path": ["thrower"], 409 | } 410 | ], 411 | } 412 | 413 | 414 | @pytest.mark.asyncio 415 | async def test_handles_syntax_errors_caught_by_graphql(client): 416 | response = await client.get(url_string(query="syntaxerror")) 417 | 418 | assert response.status == 400 419 | assert await response.json() == { 420 | "errors": [ 421 | { 422 | "locations": [{"column": 1, "line": 1}], 423 | "message": "Syntax Error: Unexpected Name 'syntaxerror'.", 424 | "path": None, 425 | }, 426 | ], 427 | } 428 | 429 | 430 | @pytest.mark.asyncio 431 | async def test_handles_errors_caused_by_a_lack_of_query(client): 432 | response = await client.get("/graphql") 433 | 434 | assert response.status == 400 435 | assert await response.json() == { 436 | "errors": [ 437 | {"message": "Must provide query string.", "locations": None, "path": None} 438 | ] 439 | } 440 | 441 | 442 | @pytest.mark.asyncio 443 | async def test_handles_batch_correctly_if_is_disabled(client): 444 | response = await client.post( 445 | "/graphql", data="[]", headers={"content-type": "application/json"}, 446 | ) 447 | 448 | assert response.status == 400 449 | assert await response.json() == { 450 | "errors": [ 451 | { 452 | "message": "Batch GraphQL requests are not enabled.", 453 | "locations": None, 454 | "path": None, 455 | } 456 | ] 457 | } 458 | 459 | 460 | @pytest.mark.asyncio 461 | async def test_handles_incomplete_json_bodies(client): 462 | response = await client.post( 463 | "/graphql", data='{"query":', headers={"content-type": "application/json"}, 464 | ) 465 | 466 | assert response.status == 400 467 | assert await response.json() == { 468 | "errors": [ 469 | { 470 | "message": "POST body sent invalid JSON.", 471 | "locations": None, 472 | "path": None, 473 | } 474 | ] 475 | } 476 | 477 | 478 | @pytest.mark.asyncio 479 | async def test_handles_plain_post_text(client): 480 | response = await client.post( 481 | url_string(variables=json.dumps({"who": "Dolly"})), 482 | data="query helloWho($who: String){ test(who: $who) }", 483 | headers={"content-type": "text/plain"}, 484 | ) 485 | assert response.status == 400 486 | assert await response.json() == { 487 | "errors": [ 488 | {"message": "Must provide query string.", "locations": None, "path": None} 489 | ] 490 | } 491 | 492 | 493 | @pytest.mark.asyncio 494 | async def test_handles_poorly_formed_variables(client): 495 | response = await client.get( 496 | url_string( 497 | query="query helloWho($who: String){ test(who: $who) }", variables="who:You" 498 | ), 499 | ) 500 | assert response.status == 400 501 | assert await response.json() == { 502 | "errors": [ 503 | {"message": "Variables are invalid JSON.", "locations": None, "path": None} 504 | ] 505 | } 506 | 507 | 508 | @pytest.mark.asyncio 509 | async def test_handles_unsupported_http_methods(client): 510 | response = await client.put(url_string(query="{test}")) 511 | assert response.status == 405 512 | assert response.headers["Allow"] in ["GET, POST", "HEAD, GET, POST, OPTIONS"] 513 | assert await response.json() == { 514 | "errors": [ 515 | { 516 | "message": "GraphQL only supports GET and POST requests.", 517 | "locations": None, 518 | "path": None, 519 | } 520 | ] 521 | } 522 | 523 | 524 | @pytest.mark.asyncio 525 | @pytest.mark.parametrize("app", [create_app()]) 526 | async def test_passes_request_into_request_context(app, client): 527 | response = await client.get(url_string(query="{request}", q="testing")) 528 | 529 | assert response.status == 200 530 | assert await response.json() == { 531 | "data": {"request": "testing"}, 532 | } 533 | 534 | 535 | @pytest.mark.asyncio 536 | @pytest.mark.parametrize("app", [create_app(context={"session": "CUSTOM CONTEXT"})]) 537 | async def test_passes_custom_context_into_context(app, client): 538 | response = await client.get(url_string(query="{context { session request }}")) 539 | 540 | _json = await response.json() 541 | assert response.status == 200 542 | assert "data" in _json 543 | assert "session" in _json["data"]["context"] 544 | assert "request" in _json["data"]["context"] 545 | assert "CUSTOM CONTEXT" in _json["data"]["context"]["session"] 546 | assert "Request" in _json["data"]["context"]["request"] 547 | 548 | 549 | @pytest.mark.asyncio 550 | @pytest.mark.parametrize("app", [create_app(context="CUSTOM CONTEXT")]) 551 | async def test_context_remapped_if_not_mapping(app, client): 552 | response = await client.get(url_string(query="{context { session request }}")) 553 | 554 | _json = await response.json() 555 | assert response.status == 200 556 | assert "data" in _json 557 | assert "session" in _json["data"]["context"] 558 | assert "request" in _json["data"]["context"] 559 | assert "CUSTOM CONTEXT" not in _json["data"]["context"]["request"] 560 | assert "Request" in _json["data"]["context"]["request"] 561 | 562 | 563 | @pytest.mark.asyncio 564 | @pytest.mark.parametrize("app", [create_app(context={"request": "test"})]) 565 | async def test_request_not_replaced(app, client): 566 | response = await client.get(url_string(query="{context { request }}")) 567 | 568 | _json = await response.json() 569 | assert response.status == 200 570 | assert _json["data"]["context"]["request"] == "test" 571 | 572 | 573 | @pytest.mark.asyncio 574 | async def test_post_multipart_data(client): 575 | query = "mutation TestMutation { writeTest { test } }" 576 | 577 | data = ( 578 | "------aiohttpgraphql\r\n" 579 | + 'Content-Disposition: form-data; name="query"\r\n' 580 | + "\r\n" 581 | + query 582 | + "\r\n" 583 | + "------aiohttpgraphql--\r\n" 584 | + "Content-Type: text/plain; charset=utf-8\r\n" 585 | + 'Content-Disposition: form-data; name="file"; filename="text1.txt"; filename*=utf-8\'\'text1.txt\r\n' # noqa: ignore 586 | + "\r\n" 587 | + "\r\n" 588 | + "------aiohttpgraphql--\r\n" 589 | ) 590 | 591 | response = await client.post( 592 | "/graphql", 593 | data=data, 594 | headers={"content-type": "multipart/form-data; boundary=----aiohttpgraphql"}, 595 | ) 596 | 597 | assert response.status == 200 598 | assert await response.json() == {"data": {u"writeTest": {u"test": u"Hello World"}}} 599 | 600 | 601 | @pytest.mark.asyncio 602 | @pytest.mark.parametrize("app", [create_app(batch=True)]) 603 | async def test_batch_allows_post_with_json_encoding(app, client): 604 | response = await client.post( 605 | "/graphql", 606 | data=json.dumps([dict(id=1, query="{test}")]), 607 | headers={"content-type": "application/json"}, 608 | ) 609 | 610 | assert response.status == 200 611 | assert await response.json() == [{"data": {"test": "Hello World"}}] 612 | 613 | 614 | @pytest.mark.asyncio 615 | @pytest.mark.parametrize("app", [create_app(batch=True)]) 616 | async def test_batch_supports_post_json_query_with_json_variables(app, client): 617 | response = await client.post( 618 | "/graphql", 619 | data=json.dumps( 620 | [ 621 | dict( 622 | id=1, 623 | query="query helloWho($who: String){ test(who: $who) }", 624 | variables={"who": "Dolly"}, 625 | ) 626 | ] 627 | ), 628 | headers={"content-type": "application/json"}, 629 | ) 630 | 631 | assert response.status == 200 632 | assert await response.json() == [{"data": {"test": "Hello Dolly"}}] 633 | 634 | 635 | @pytest.mark.asyncio 636 | @pytest.mark.parametrize("app", [create_app(batch=True)]) 637 | async def test_batch_allows_post_with_operation_name(app, client): 638 | response = await client.post( 639 | "/graphql", 640 | data=json.dumps( 641 | [ 642 | dict( 643 | id=1, 644 | query=""" 645 | query helloYou { test(who: "You"), ...shared } 646 | query helloWorld { test(who: "World"), ...shared } 647 | query helloDolly { test(who: "Dolly"), ...shared } 648 | fragment shared on QueryRoot { 649 | shared: test(who: "Everyone") 650 | } 651 | """, 652 | operationName="helloWorld", 653 | ) 654 | ] 655 | ), 656 | headers={"content-type": "application/json"}, 657 | ) 658 | 659 | assert response.status == 200 660 | assert await response.json() == [ 661 | {"data": {"test": "Hello World", "shared": "Hello Everyone"}} 662 | ] 663 | 664 | 665 | @pytest.mark.asyncio 666 | @pytest.mark.parametrize("app", [create_app(schema=AsyncSchema, enable_async=True)]) 667 | async def test_async_schema(app, client): 668 | response = await client.get(url_string(query="{a,b,c}")) 669 | 670 | assert response.status == 200 671 | assert await response.json() == {"data": {"a": "hey", "b": "hey2", "c": "hey3"}} 672 | 673 | 674 | @pytest.mark.asyncio 675 | async def test_preflight_request(client): 676 | response = await client.options( 677 | "/graphql", headers={"Access-Control-Request-Method": "POST"}, 678 | ) 679 | 680 | assert response.status == 200 681 | 682 | 683 | @pytest.mark.asyncio 684 | async def test_preflight_incorrect_request(client): 685 | response = await client.options( 686 | "/graphql", headers={"Access-Control-Request-Method": "OPTIONS"}, 687 | ) 688 | 689 | assert response.status == 400 690 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{36,37,38} 4 | flake8,import-order,manifest 5 | ; requires = tox-conda 6 | 7 | [testenv] 8 | passenv = * 9 | setenv = 10 | PYTHONPATH = {toxinidir} 11 | install_command = python -m pip install --pre --ignore-installed {opts} {packages} 12 | deps = -e.[test] 13 | commands = 14 | pytest tests --cov-report=term-missing --cov=aiohttp_graphql {posargs} 15 | 16 | [testenv:flake8] 17 | basepython=python3.8 18 | deps = -e.[dev] 19 | commands = 20 | flake8 setup.py aiohttp_graphql tests 21 | 22 | [testenv:import-order] 23 | basepython=python3.8 24 | deps = -e.[dev] 25 | commands = 26 | isort -rc aiohttp_graphql/ tests/ 27 | 28 | [testenv:manifest] 29 | basepython = python3.8 30 | deps = -e.[dev] 31 | commands = 32 | check-manifest -v 33 | --------------------------------------------------------------------------------