├── tests ├── __init__.py ├── app.py ├── schema.py ├── test_graphiqlview.py └── test_graphqlview.py ├── sanic_graphql └── __init__.py ├── Makefile ├── MANIFEST.in ├── setup.cfg ├── .travis.yml ├── tox.ini ├── LICENSE ├── setup.py ├── .gitignore └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sanic_graphql/__init__.py: -------------------------------------------------------------------------------- 1 | from graphql_server.sanic import GraphQLView 2 | 3 | __all__ = ['GraphQLView'] 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | dev-setup: 2 | python pip install -e ".[test]" 3 | 4 | tests: 5 | py.test tests --cov=sanic_graphql -vv -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | 4 | include tox.ini 5 | include Makefile 6 | 7 | recursive-include sanic_graphql *.py 8 | recursive-include tests *.py 9 | 10 | global-exclude *.py[co] __pycache__ 11 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | - pip install coveralls 17 | - coveralls -------------------------------------------------------------------------------- /tests/app.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlencode 2 | 3 | from sanic import Sanic 4 | from sanic.testing import SanicTestClient 5 | 6 | from sanic_graphql import GraphQLView 7 | 8 | from .schema import Schema 9 | 10 | 11 | def create_app(path="/graphql", **kwargs): 12 | app = Sanic(__name__) 13 | app.debug = True 14 | 15 | schema = kwargs.pop("schema", None) or Schema 16 | app.add_route(GraphQLView.as_view(schema=schema, **kwargs), path) 17 | 18 | app.client = SanicTestClient(app) 19 | return app 20 | 21 | 22 | def url_string(uri="/graphql", **url_params): 23 | string = "/graphql" 24 | 25 | if url_params: 26 | string += "?" + urlencode(url_params) 27 | 28 | return string 29 | -------------------------------------------------------------------------------- /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=sanic_graphql {posargs} 15 | 16 | [testenv:flake8] 17 | basepython=python3.8 18 | deps = -e.[dev] 19 | commands = 20 | flake8 setup.py sanic_graphql tests 21 | 22 | [testenv:import-order] 23 | basepython=python3.8 24 | deps = -e.[dev] 25 | commands = 26 | isort -rc sanic_graphql/ tests/ 27 | 28 | [testenv:manifest] 29 | basepython = python3.8 30 | deps = -e.[dev] 31 | commands = 32 | check-manifest -v -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Sergey Porivaev 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 sanic-graphql are held by Syrus Akbary, 2015 24 | as part of project flask-graphql. All other copyright for project sanic-graphql 25 | are held by Sergey Porivaev. 26 | 27 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | install_requires = [ 4 | "graphql-server[sanic]>=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 | "aiohttp>=3.5.0,<4", 12 | "Jinja2>=2.10.1,<3", 13 | ] 14 | 15 | dev_requires = [ 16 | "flake8>=3.7,<4", 17 | "isort>=4,<5", 18 | "check-manifest>=0.40,<1", 19 | ] + tests_requires 20 | 21 | with open("README.md", encoding="utf-8") as readme_file: 22 | readme = readme_file.read() 23 | 24 | setup( 25 | name="Sanic-GraphQL", 26 | version="1.2.0", 27 | description="Adds GraphQL support to your Sanic application", 28 | long_description=readme, 29 | url="https://github.com/graphql-python/sanic-graphql", 30 | download_url="https://github.com/graphql-python/sanic-graphql/releases", 31 | author="Sergey Porivaev", 32 | author_email="porivaevs@gmail.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 sanic", 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/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: info.context["request"].args.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 | MutationRootType = GraphQLObjectType( 44 | name="MutationRoot", 45 | fields={ 46 | "writeTest": GraphQLField(type_=QueryRootType, resolve=lambda *_: QueryRootType) 47 | }, 48 | ) 49 | 50 | Schema = GraphQLSchema(QueryRootType, MutationRootType) 51 | 52 | 53 | # Schema with async methods 54 | async def resolver_field_async_1(_obj, info): 55 | await asyncio.sleep(0.001) 56 | return "hey" 57 | 58 | 59 | async def resolver_field_async_2(_obj, info): 60 | await asyncio.sleep(0.003) 61 | return "hey2" 62 | 63 | 64 | def resolver_field_sync(_obj, info): 65 | return "hey3" 66 | 67 | 68 | AsyncQueryType = GraphQLObjectType( 69 | name="AsyncQueryType", 70 | fields={ 71 | "a": GraphQLField(GraphQLString, resolve=resolver_field_async_1), 72 | "b": GraphQLField(GraphQLString, resolve=resolver_field_async_2), 73 | "c": GraphQLField(GraphQLString, resolve=resolver_field_sync), 74 | }, 75 | ) 76 | 77 | AsyncSchema = GraphQLSchema(AsyncQueryType) 78 | -------------------------------------------------------------------------------- /tests/test_graphiqlview.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from jinja2 import Environment 3 | 4 | from .app import create_app, url_string 5 | from .schema import AsyncSchema 6 | 7 | 8 | @pytest.fixture 9 | def pretty_response(): 10 | return ( 11 | "{\n" 12 | ' "data": {\n' 13 | ' "test": "Hello World"\n' 14 | " }\n" 15 | "}".replace('"', '\\"').replace("\n", "\\n") 16 | ) 17 | 18 | 19 | @pytest.mark.parametrize("app", [create_app(graphiql=True)]) 20 | def test_graphiql_is_enabled(app): 21 | _, response = app.client.get( 22 | uri=url_string(query="{test}"), headers={"Accept": "text/html"} 23 | ) 24 | assert response.status == 200 25 | 26 | 27 | @pytest.mark.parametrize("app", [create_app(graphiql=True)]) 28 | def test_graphiql_simple_renderer(app, pretty_response): 29 | _, response = app.client.get( 30 | uri=url_string(query="{test}"), headers={"Accept": "text/html"} 31 | ) 32 | assert response.status == 200 33 | assert pretty_response in response.body.decode("utf-8") 34 | 35 | 36 | @pytest.mark.parametrize("app", [create_app(graphiql=True, jinja_env=Environment())]) 37 | def test_graphiql_jinja_renderer(app, pretty_response): 38 | _, response = app.client.get( 39 | uri=url_string(query="{test}"), headers={"Accept": "text/html"} 40 | ) 41 | assert response.status == 200 42 | assert pretty_response in response.body.decode("utf-8") 43 | 44 | 45 | @pytest.mark.parametrize( 46 | "app", [create_app(graphiql=True, jinja_env=Environment(enable_async=True))] 47 | ) 48 | def test_graphiql_jinja_async_renderer(app, pretty_response): 49 | _, response = app.client.get( 50 | uri=url_string(query="{test}"), headers={"Accept": "text/html"} 51 | ) 52 | assert response.status == 200 53 | assert pretty_response in response.body.decode("utf-8") 54 | 55 | 56 | @pytest.mark.parametrize("app", [create_app(graphiql=True)]) 57 | def test_graphiql_html_is_not_accepted(app): 58 | _, response = app.client.get( 59 | uri=url_string(), headers={"Accept": "application/json"} 60 | ) 61 | assert response.status == 400 62 | 63 | 64 | @pytest.mark.parametrize( 65 | "app", [create_app(graphiql=True, schema=AsyncSchema, enable_async=True)] 66 | ) 67 | def test_graphiql_asyncio_schema(app): 68 | query = "{a,b,c}" 69 | _, response = app.client.get( 70 | uri=url_string(query=query), headers={"Accept": "text/html"} 71 | ) 72 | 73 | expected_response = ( 74 | ( 75 | "{\n" 76 | ' "data": {\n' 77 | ' "a": "hey",\n' 78 | ' "b": "hey2",\n' 79 | ' "c": "hey3"\n' 80 | " }\n" 81 | "}" 82 | ) 83 | .replace('"', '\\"') 84 | .replace("\n", "\\n") 85 | ) 86 | 87 | assert response.status == 200 88 | assert expected_response in response.body.decode("utf-8") 89 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/graphql-python/sanic-graphql.svg?branch=master)](https://travis-ci.org/graphql-python/sanic-graphql) 2 | [![Coverage Status](https://coveralls.io/repos/github/graphql-python/sanic-graphql/badge.svg?branch=master)](https://coveralls.io/github/graphql-python/sanic-graphql?branch=master) 3 | [![PyPI version](https://badge.fury.io/py/Sanic-GraphQL.svg)](https://badge.fury.io/py/Sanic-GraphQL) 4 | 5 | Sanic-GraphQL 6 | ============= 7 | 8 | Adds [GraphQL] support to your [Sanic] application. 9 | 10 | Based on [flask-graphql] by [Syrus Akbary]. 11 | 12 | Usage 13 | ----- 14 | 15 | Use the `GraphQLView` view from`sanic_graphql` 16 | 17 | ```python 18 | from sanic_graphql import GraphQLView 19 | from sanic import Sanic 20 | 21 | from schema import schema 22 | 23 | app = Sanic(name="Sanic Graphql App") 24 | 25 | app.add_route( 26 | GraphQLView.as_view(schema=schema, graphiql=True), 27 | '/graphql' 28 | ) 29 | 30 | # Optional, for adding batch query support (used in Apollo-Client) 31 | app.add_route( 32 | GraphQLView.as_view(schema=schema, batch=True), 33 | '/graphql/batch' 34 | ) 35 | 36 | if __name__ == '__main__': 37 | app.run(host='0.0.0.0', port=8000) 38 | ``` 39 | 40 | This will add `/graphql` endpoint to your app and enable the GraphiQL IDE. 41 | 42 | ### Supported options for GraphQLView 43 | 44 | * `schema`: The `GraphQLSchema` object that you want the view to execute when it gets a valid request. 45 | * `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`. 46 | * `root_value`: The `root_value` you want to provide to graphql `execute`. 47 | * `pretty`: Whether or not you want the response to be pretty printed JSON. 48 | * `graphiql`: If `True`, may present [GraphiQL](https://github.com/graphql/graphiql) when loaded directly from a browser (a useful tool for debugging and exploration). 49 | * `graphiql_version`: The graphiql version to load. Defaults to **"1.0.3"**. 50 | * `graphiql_template`: Inject a Jinja template string to customize GraphiQL. 51 | * `graphiql_html_title`: The graphiql title to display. Defaults to **"GraphiQL"**. 52 | * `jinja_env`: Sets jinja environment to be used to process GraphiQL template. If Jinja’s async mode is enabled (by `enable_async=True`), uses 53 | `Template.render_async` instead of `Template.render`. If environment is not set, fallbacks to simple regex-based renderer. 54 | * `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)) 55 | * `middleware`: A list of graphql [middlewares](http://docs.graphene-python.org/en/latest/execution/middleware/). 56 | * `max_age`: Sets the response header Access-Control-Max-Age for preflight requests. 57 | * `encode`: the encoder to use for responses (sensibly defaults to `graphql_server.json_encode`). 58 | * `format_error`: the error formatter to use for responses (sensibly defaults to `graphql_server.default_format_error`. 59 | * `enable_async`: whether `async` mode will be enabled. 60 | * `subscriptions`: The GraphiQL socket endpoint for using subscriptions in graphql-ws. 61 | * `headers`: An optional GraphQL string to use as the initial displayed request headers, if not provided, the stored headers will be used. 62 | * `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. 63 | * `header_editor_enabled`: An optional boolean which enables the header editor when true. Defaults to **false**. 64 | * `should_persist_headers`: An optional boolean which enables to persist headers to storage when true. Defaults to **false**. 65 | 66 | 67 | You can also subclass `GraphQLView` and overwrite `get_root_value(self, request)` to have a dynamic root value per request. 68 | 69 | ```python 70 | class UserRootValue(GraphQLView): 71 | def get_root_value(self, request): 72 | return request.user 73 | ``` 74 | 75 | 76 | ## Contributing 77 | Since v3, `sanic-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). 78 | 79 | 80 | ## License 81 | 82 | Copyright for portions of project [sanic-graphql] are held by [Syrus Akbary] as part of project [flask-graphql]. All other copyright 83 | for project [sanic-graphql] are held by [Sergey Porivaev]. 84 | 85 | This project is licensed under MIT License. 86 | 87 | [GraphQL]: http://graphql.org/ 88 | [Sanic]: https://github.com/channelcat/sanic 89 | [flask-graphql]: https://github.com/graphql-python/flask-graphql 90 | [Syrus Akbary]: https://github.com/syrusakbary 91 | [GraphiQL]: https://github.com/graphql/graphiql 92 | [Apollo-Client]: http://dev.apollodata.com/core/network.html#query-batching 93 | [ReactRelayNetworkLayer]: https://github.com/nodkz/react-relay-network-layer 94 | [Sergey Porivaev]: https://github.com/grazor 95 | [sanic-graphql]: https://github.com/grazor/sanic-graphql 96 | -------------------------------------------------------------------------------- /tests/test_graphqlview.py: -------------------------------------------------------------------------------- 1 | import json 2 | from urllib.parse import urlencode 3 | 4 | import pytest 5 | 6 | from .app import create_app, url_string 7 | from .schema import AsyncSchema 8 | 9 | 10 | def response_json(response): 11 | return json.loads(response.body.decode()) 12 | 13 | 14 | def json_dump_kwarg(**kwargs): 15 | return json.dumps(kwargs) 16 | 17 | 18 | def json_dump_kwarg_list(**kwargs): 19 | return json.dumps([kwargs]) 20 | 21 | 22 | @pytest.mark.parametrize("app", [create_app()]) 23 | def test_allows_get_with_query_param(app): 24 | _, response = app.client.get(uri=url_string(query="{test}")) 25 | 26 | assert response.status == 200 27 | assert response_json(response) == {"data": {"test": "Hello World"}} 28 | 29 | 30 | @pytest.mark.parametrize("app", [create_app()]) 31 | def test_allows_get_with_variable_values(app): 32 | _, response = app.client.get( 33 | uri=url_string( 34 | query="query helloWho($who: String){ test(who: $who) }", 35 | variables=json.dumps({"who": "Dolly"}), 36 | ) 37 | ) 38 | 39 | assert response.status == 200 40 | assert response_json(response) == {"data": {"test": "Hello Dolly"}} 41 | 42 | 43 | @pytest.mark.parametrize("app", [create_app()]) 44 | def test_allows_get_with_operation_name(app): 45 | _, response = app.client.get( 46 | uri=url_string( 47 | query=""" 48 | query helloYou { test(who: "You"), ...shared } 49 | query helloWorld { test(who: "World"), ...shared } 50 | query helloDolly { test(who: "Dolly"), ...shared } 51 | fragment shared on QueryRoot { 52 | shared: test(who: "Everyone") 53 | } 54 | """, 55 | operationName="helloWorld", 56 | ) 57 | ) 58 | 59 | assert response.status == 200 60 | assert response_json(response) == { 61 | "data": {"test": "Hello World", "shared": "Hello Everyone"} 62 | } 63 | 64 | 65 | @pytest.mark.parametrize("app", [create_app()]) 66 | def test_reports_validation_errors(app): 67 | _, response = app.client.get( 68 | uri=url_string(query="{ test, unknownOne, unknownTwo }") 69 | ) 70 | 71 | assert response.status == 400 72 | assert response_json(response) == { 73 | "errors": [ 74 | { 75 | "message": "Cannot query field 'unknownOne' on type 'QueryRoot'.", 76 | "locations": [{"line": 1, "column": 9}], 77 | "path": None, 78 | }, 79 | { 80 | "message": "Cannot query field 'unknownTwo' on type 'QueryRoot'.", 81 | "locations": [{"line": 1, "column": 21}], 82 | "path": None, 83 | }, 84 | ] 85 | } 86 | 87 | 88 | @pytest.mark.parametrize("app", [create_app()]) 89 | def test_errors_when_missing_operation_name(app): 90 | _, response = app.client.get( 91 | uri=url_string( 92 | query=""" 93 | query TestQuery { test } 94 | mutation TestMutation { writeTest { test } } 95 | """ 96 | ) 97 | ) 98 | 99 | assert response.status == 400 100 | assert response_json(response) == { 101 | "errors": [ 102 | { 103 | "locations": None, 104 | "message": "Must provide operation name if query contains multiple operations.", 105 | "path": None, 106 | } 107 | ] 108 | } 109 | 110 | 111 | @pytest.mark.parametrize("app", [create_app()]) 112 | def test_errors_when_sending_a_mutation_via_get(app): 113 | _, response = app.client.get( 114 | uri=url_string( 115 | query=""" 116 | mutation TestMutation { writeTest { test } } 117 | """ 118 | ) 119 | ) 120 | assert response.status == 405 121 | assert response_json(response) == { 122 | "errors": [ 123 | { 124 | "locations": None, 125 | "message": "Can only perform a mutation operation from a POST request.", 126 | "path": None, 127 | } 128 | ] 129 | } 130 | 131 | 132 | @pytest.mark.parametrize("app", [create_app()]) 133 | def test_errors_when_selecting_a_mutation_within_a_get(app): 134 | _, response = app.client.get( 135 | uri=url_string( 136 | query=""" 137 | query TestQuery { test } 138 | mutation TestMutation { writeTest { test } } 139 | """, 140 | operationName="TestMutation", 141 | ) 142 | ) 143 | 144 | assert response.status == 405 145 | assert response_json(response) == { 146 | "errors": [ 147 | { 148 | "locations": None, 149 | "message": "Can only perform a mutation operation from a POST request.", 150 | "path": None, 151 | } 152 | ] 153 | } 154 | 155 | 156 | @pytest.mark.parametrize("app", [create_app()]) 157 | def test_allows_mutation_to_exist_within_a_get(app): 158 | _, response = app.client.get( 159 | uri=url_string( 160 | query=""" 161 | query TestQuery { test } 162 | mutation TestMutation { writeTest { test } } 163 | """, 164 | operationName="TestQuery", 165 | ) 166 | ) 167 | 168 | assert response.status == 200 169 | assert response_json(response) == {"data": {"test": "Hello World"}} 170 | 171 | 172 | @pytest.mark.parametrize("app", [create_app()]) 173 | def test_allows_post_with_json_encoding(app): 174 | _, response = app.client.post( 175 | uri=url_string(), 176 | data=json_dump_kwarg(query="{test}"), 177 | headers={"content-type": "application/json"}, 178 | ) 179 | 180 | assert response.status == 200 181 | assert response_json(response) == {"data": {"test": "Hello World"}} 182 | 183 | 184 | @pytest.mark.parametrize("app", [create_app()]) 185 | def test_allows_sending_a_mutation_via_post(app): 186 | _, response = app.client.post( 187 | uri=url_string(), 188 | data=json_dump_kwarg(query="mutation TestMutation { writeTest { test } }"), 189 | headers={"content-type": "application/json"}, 190 | ) 191 | 192 | assert response.status == 200 193 | assert response_json(response) == {"data": {"writeTest": {"test": "Hello World"}}} 194 | 195 | 196 | @pytest.mark.parametrize("app", [create_app()]) 197 | def test_allows_post_with_url_encoding(app): 198 | # Example of how sanic does send data using url enconding 199 | # can be found at their repo. 200 | # https://github.com/huge-success/sanic/blob/master/tests/test_requests.py#L927 201 | payload = "query={test}" 202 | _, response = app.client.post( 203 | uri=url_string(), 204 | data=payload, 205 | headers={"content-type": "application/x-www-form-urlencoded"}, 206 | ) 207 | 208 | assert response.status == 200 209 | assert response_json(response) == {"data": {"test": "Hello World"}} 210 | 211 | 212 | @pytest.mark.parametrize("app", [create_app()]) 213 | def test_supports_post_json_query_with_string_variables(app): 214 | _, response = app.client.post( 215 | uri=url_string(), 216 | data=json_dump_kwarg( 217 | query="query helloWho($who: String){ test(who: $who) }", 218 | variables=json.dumps({"who": "Dolly"}), 219 | ), 220 | headers={"content-type": "application/json"}, 221 | ) 222 | 223 | assert response.status == 200 224 | assert response_json(response) == {"data": {"test": "Hello Dolly"}} 225 | 226 | 227 | @pytest.mark.parametrize("app", [create_app()]) 228 | def test_supports_post_json_query_with_json_variables(app): 229 | _, response = app.client.post( 230 | uri=url_string(), 231 | data=json_dump_kwarg( 232 | query="query helloWho($who: String){ test(who: $who) }", 233 | variables={"who": "Dolly"}, 234 | ), 235 | headers={"content-type": "application/json"}, 236 | ) 237 | 238 | assert response.status == 200 239 | assert response_json(response) == {"data": {"test": "Hello Dolly"}} 240 | 241 | 242 | @pytest.mark.parametrize("app", [create_app()]) 243 | def test_supports_post_url_encoded_query_with_string_variables(app): 244 | _, response = app.client.post( 245 | uri=url_string(), 246 | data=urlencode( 247 | dict( 248 | query="query helloWho($who: String){ test(who: $who) }", 249 | variables=json.dumps({"who": "Dolly"}), 250 | ) 251 | ), 252 | headers={"content-type": "application/x-www-form-urlencoded"}, 253 | ) 254 | 255 | assert response.status == 200 256 | assert response_json(response) == {"data": {"test": "Hello Dolly"}} 257 | 258 | 259 | @pytest.mark.parametrize("app", [create_app()]) 260 | def test_supports_post_json_query_with_get_variable_values(app): 261 | _, response = app.client.post( 262 | uri=url_string(variables=json.dumps({"who": "Dolly"})), 263 | data=json_dump_kwarg(query="query helloWho($who: String){ test(who: $who) }",), 264 | headers={"content-type": "application/json"}, 265 | ) 266 | 267 | assert response.status == 200 268 | assert response_json(response) == {"data": {"test": "Hello Dolly"}} 269 | 270 | 271 | @pytest.mark.parametrize("app", [create_app()]) 272 | def test_post_url_encoded_query_with_get_variable_values(app): 273 | _, response = app.client.post( 274 | uri=url_string(variables=json.dumps({"who": "Dolly"})), 275 | data=urlencode(dict(query="query helloWho($who: String){ test(who: $who) }",)), 276 | headers={"content-type": "application/x-www-form-urlencoded"}, 277 | ) 278 | 279 | assert response.status == 200 280 | assert response_json(response) == {"data": {"test": "Hello Dolly"}} 281 | 282 | 283 | @pytest.mark.parametrize("app", [create_app()]) 284 | def test_supports_post_raw_text_query_with_get_variable_values(app): 285 | _, response = app.client.post( 286 | uri=url_string(variables=json.dumps({"who": "Dolly"})), 287 | data="query helloWho($who: String){ test(who: $who) }", 288 | headers={"content-type": "application/graphql"}, 289 | ) 290 | 291 | assert response.status == 200 292 | assert response_json(response) == {"data": {"test": "Hello Dolly"}} 293 | 294 | 295 | @pytest.mark.parametrize("app", [create_app()]) 296 | def test_allows_post_with_operation_name(app): 297 | _, response = app.client.post( 298 | uri=url_string(), 299 | data=json_dump_kwarg( 300 | query=""" 301 | query helloYou { test(who: "You"), ...shared } 302 | query helloWorld { test(who: "World"), ...shared } 303 | query helloDolly { test(who: "Dolly"), ...shared } 304 | fragment shared on QueryRoot { 305 | shared: test(who: "Everyone") 306 | } 307 | """, 308 | operationName="helloWorld", 309 | ), 310 | headers={"content-type": "application/json"}, 311 | ) 312 | 313 | assert response.status == 200 314 | assert response_json(response) == { 315 | "data": {"test": "Hello World", "shared": "Hello Everyone"} 316 | } 317 | 318 | 319 | @pytest.mark.parametrize("app", [create_app()]) 320 | def test_allows_post_with_get_operation_name(app): 321 | _, response = app.client.post( 322 | uri=url_string(operationName="helloWorld"), 323 | data=""" 324 | query helloYou { test(who: "You"), ...shared } 325 | query helloWorld { test(who: "World"), ...shared } 326 | query helloDolly { test(who: "Dolly"), ...shared } 327 | fragment shared on QueryRoot { 328 | shared: test(who: "Everyone") 329 | } 330 | """, 331 | headers={"content-type": "application/graphql"}, 332 | ) 333 | 334 | assert response.status == 200 335 | assert response_json(response) == { 336 | "data": {"test": "Hello World", "shared": "Hello Everyone"} 337 | } 338 | 339 | 340 | @pytest.mark.parametrize("app", [create_app(pretty=True)]) 341 | def test_supports_pretty_printing(app): 342 | _, response = app.client.get(uri=url_string(query="{test}")) 343 | 344 | assert response.body.decode() == ( 345 | "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" 346 | ) 347 | 348 | 349 | @pytest.mark.parametrize("app", [create_app(pretty=False)]) 350 | def test_not_pretty_by_default(app): 351 | _, response = app.client.get(url_string(query="{test}")) 352 | 353 | assert response.body.decode() == '{"data":{"test":"Hello World"}}' 354 | 355 | 356 | @pytest.mark.parametrize("app", [create_app()]) 357 | def test_supports_pretty_printing_by_request(app): 358 | _, response = app.client.get(uri=url_string(query="{test}", pretty="1")) 359 | 360 | assert response.body.decode() == ( 361 | "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" 362 | ) 363 | 364 | 365 | @pytest.mark.parametrize("app", [create_app()]) 366 | def test_handles_field_errors_caught_by_graphql(app): 367 | _, response = app.client.get(uri=url_string(query="{thrower}")) 368 | assert response.status == 200 369 | assert response_json(response) == { 370 | "data": None, 371 | "errors": [ 372 | { 373 | "locations": [{"column": 2, "line": 1}], 374 | "message": "Throws!", 375 | "path": ["thrower"], 376 | } 377 | ], 378 | } 379 | 380 | 381 | @pytest.mark.parametrize("app", [create_app()]) 382 | def test_handles_syntax_errors_caught_by_graphql(app): 383 | _, response = app.client.get(uri=url_string(query="syntaxerror")) 384 | assert response.status == 400 385 | assert response_json(response) == { 386 | "errors": [ 387 | { 388 | "locations": [{"column": 1, "line": 1}], 389 | "message": "Syntax Error: Unexpected Name 'syntaxerror'.", 390 | "path": None, 391 | } 392 | ] 393 | } 394 | 395 | 396 | @pytest.mark.parametrize("app", [create_app()]) 397 | def test_handles_errors_caused_by_a_lack_of_query(app): 398 | _, response = app.client.get(uri=url_string()) 399 | 400 | assert response.status == 400 401 | assert response_json(response) == { 402 | "errors": [ 403 | {"locations": None, "message": "Must provide query string.", "path": None} 404 | ] 405 | } 406 | 407 | 408 | @pytest.mark.parametrize("app", [create_app()]) 409 | def test_handles_batch_correctly_if_is_disabled(app): 410 | _, response = app.client.post( 411 | uri=url_string(), data="[]", headers={"content-type": "application/json"} 412 | ) 413 | 414 | assert response.status == 400 415 | assert response_json(response) == { 416 | "errors": [ 417 | { 418 | "locations": None, 419 | "message": "Batch GraphQL requests are not enabled.", 420 | "path": None, 421 | } 422 | ] 423 | } 424 | 425 | 426 | @pytest.mark.parametrize("app", [create_app()]) 427 | def test_handles_incomplete_json_bodies(app): 428 | _, response = app.client.post( 429 | uri=url_string(), data='{"query":', headers={"content-type": "application/json"} 430 | ) 431 | 432 | assert response.status == 400 433 | assert response_json(response) == { 434 | "errors": [ 435 | {"locations": None, "message": "POST body sent invalid JSON.", "path": None} 436 | ] 437 | } 438 | 439 | 440 | @pytest.mark.parametrize("app", [create_app()]) 441 | def test_handles_plain_post_text(app): 442 | _, response = app.client.post( 443 | uri=url_string(variables=json.dumps({"who": "Dolly"})), 444 | data="query helloWho($who: String){ test(who: $who) }", 445 | headers={"content-type": "text/plain"}, 446 | ) 447 | assert response.status == 400 448 | assert response_json(response) == { 449 | "errors": [ 450 | {"locations": None, "message": "Must provide query string.", "path": None} 451 | ] 452 | } 453 | 454 | 455 | @pytest.mark.parametrize("app", [create_app()]) 456 | def test_handles_poorly_formed_variables(app): 457 | _, response = app.client.get( 458 | uri=url_string( 459 | query="query helloWho($who: String){ test(who: $who) }", variables="who:You" 460 | ) 461 | ) 462 | assert response.status == 400 463 | assert response_json(response) == { 464 | "errors": [ 465 | {"locations": None, "message": "Variables are invalid JSON.", "path": None} 466 | ] 467 | } 468 | 469 | 470 | @pytest.mark.parametrize("app", [create_app()]) 471 | def test_handles_unsupported_http_methods(app): 472 | _, response = app.client.put(uri=url_string(query="{test}")) 473 | assert response.status == 405 474 | assert response.headers["Allow"] in ["GET, POST", "HEAD, GET, POST, OPTIONS"] 475 | assert response_json(response) == { 476 | "errors": [ 477 | { 478 | "locations": None, 479 | "message": "GraphQL only supports GET and POST requests.", 480 | "path": None, 481 | } 482 | ] 483 | } 484 | 485 | 486 | @pytest.mark.parametrize("app", [create_app()]) 487 | def test_passes_request_into_request_context(app): 488 | _, response = app.client.get(uri=url_string(query="{request}", q="testing")) 489 | 490 | assert response.status == 200 491 | assert response_json(response) == {"data": {"request": "testing"}} 492 | 493 | 494 | @pytest.mark.parametrize("app", [create_app(context={"session": "CUSTOM CONTEXT"})]) 495 | def test_passes_custom_context_into_context(app): 496 | _, response = app.client.get(uri=url_string(query="{context { session request }}")) 497 | 498 | assert response.status_code == 200 499 | res = response_json(response) 500 | assert "data" in res 501 | assert "session" in res["data"]["context"] 502 | assert "request" in res["data"]["context"] 503 | assert "CUSTOM CONTEXT" in res["data"]["context"]["session"] 504 | assert "Request" in res["data"]["context"]["request"] 505 | 506 | 507 | @pytest.mark.parametrize("app", [create_app(context="CUSTOM CONTEXT")]) 508 | def test_context_remapped_if_not_mapping(app): 509 | _, response = app.client.get(uri=url_string(query="{context { session request }}")) 510 | 511 | assert response.status_code == 200 512 | res = response_json(response) 513 | assert "data" in res 514 | assert "session" in res["data"]["context"] 515 | assert "request" in res["data"]["context"] 516 | assert "CUSTOM CONTEXT" not in res["data"]["context"]["request"] 517 | assert "Request" in res["data"]["context"]["request"] 518 | 519 | 520 | @pytest.mark.parametrize("app", [create_app()]) 521 | def test_post_multipart_data(app): 522 | query = "mutation TestMutation { writeTest { test } }" 523 | 524 | data = ( 525 | "------sanicgraphql\r\n" 526 | + 'Content-Disposition: form-data; name="query"\r\n' 527 | + "\r\n" 528 | + query 529 | + "\r\n" 530 | + "------sanicgraphql--\r\n" 531 | + "Content-Type: text/plain; charset=utf-8\r\n" 532 | + 'Content-Disposition: form-data; name="file"; filename="text1.txt"; filename*=utf-8\'\'text1.txt\r\n' 533 | + "\r\n" 534 | + "\r\n" 535 | + "------sanicgraphql--\r\n" 536 | ) 537 | 538 | _, response = app.client.post( 539 | uri=url_string(), 540 | data=data, 541 | headers={"content-type": "multipart/form-data; boundary=----sanicgraphql"}, 542 | ) 543 | 544 | assert response.status == 200 545 | assert response_json(response) == { 546 | "data": {u"writeTest": {u"test": u"Hello World"}} 547 | } 548 | 549 | 550 | @pytest.mark.parametrize("app", [create_app(batch=True)]) 551 | def test_batch_allows_post_with_json_encoding(app): 552 | _, response = app.client.post( 553 | uri=url_string(), 554 | data=json_dump_kwarg_list(id=1, query="{test}"), 555 | headers={"content-type": "application/json"}, 556 | ) 557 | 558 | assert response.status == 200 559 | assert response_json(response) == [{"data": {"test": "Hello World"}}] 560 | 561 | 562 | @pytest.mark.parametrize("app", [create_app(batch=True)]) 563 | def test_batch_supports_post_json_query_with_json_variables(app): 564 | _, response = app.client.post( 565 | uri=url_string(), 566 | data=json_dump_kwarg_list( 567 | id=1, 568 | query="query helloWho($who: String){ test(who: $who) }", 569 | variables={"who": "Dolly"}, 570 | ), 571 | headers={"content-type": "application/json"}, 572 | ) 573 | 574 | assert response.status == 200 575 | assert response_json(response) == [{"data": {"test": "Hello Dolly"}}] 576 | 577 | 578 | @pytest.mark.parametrize("app", [create_app(batch=True)]) 579 | def test_batch_allows_post_with_operation_name(app): 580 | _, response = app.client.post( 581 | uri=url_string(), 582 | data=json_dump_kwarg_list( 583 | id=1, 584 | query=""" 585 | query helloYou { test(who: "You"), ...shared } 586 | query helloWorld { test(who: "World"), ...shared } 587 | query helloDolly { test(who: "Dolly"), ...shared } 588 | fragment shared on QueryRoot { 589 | shared: test(who: "Everyone") 590 | } 591 | """, 592 | operationName="helloWorld", 593 | ), 594 | headers={"content-type": "application/json"}, 595 | ) 596 | 597 | assert response.status == 200 598 | assert response_json(response) == [ 599 | {"data": {"test": "Hello World", "shared": "Hello Everyone"}} 600 | ] 601 | 602 | 603 | @pytest.mark.parametrize("app", [create_app(schema=AsyncSchema, enable_async=True)]) 604 | def test_async_schema(app): 605 | query = "{a,b,c}" 606 | _, response = app.client.get(uri=url_string(query=query)) 607 | 608 | assert response.status == 200 609 | assert response_json(response) == {"data": {"a": "hey", "b": "hey2", "c": "hey3"}} 610 | 611 | 612 | @pytest.mark.parametrize("app", [create_app()]) 613 | def test_preflight_request(app): 614 | _, response = app.client.options( 615 | uri=url_string(), headers={"Access-Control-Request-Method": "POST"} 616 | ) 617 | 618 | assert response.status == 200 619 | 620 | 621 | @pytest.mark.parametrize("app", [create_app()]) 622 | def test_preflight_incorrect_request(app): 623 | _, response = app.client.options( 624 | uri=url_string(), headers={"Access-Control-Request-Method": "OPTIONS"} 625 | ) 626 | 627 | assert response.status == 400 628 | --------------------------------------------------------------------------------