├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── flask_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 | - pip install coveralls 17 | - coveralls 18 | 19 | deploy: 20 | provider: pypi 21 | user: syrusakbary 22 | on: 23 | tags: true 24 | password: 25 | secure: GB3YHihKAbmfh9kzMxzsZQDMf5h1aIJYwESfaYMc4DjMA1Qms+ZhBN2RiH3irwJ6FW47ZsS/O6ZsrlNxu75J/Mc1NJfzFev1d0xt7cYp+s0umYHhheelR6wmP8KOt3ugK7qmWuk5bykljpxsRKzKJCvGH+LOM7mDQy3NZOfYPTAM2znWjuBr+X4iUv6pUCKk5N20GBbs9T+jNttE7K8TH4zuXCWxgbE7xVHth76pB5Q/9FZkB8hZQ7K2esO3QyajDO7FzsOk8Z/jXRJ3MOxZCI3vGgmSzKTH4fMqmSrtyr1sCaBO5tgS8ytqQBjsuV1vIWl+75bXrAXfdkin63zMne4Rsb+uSWj3djP+iy2yML8a2mWMizxr803v8lwaGnMZ26f4rwdZnHGUPlTp3geVKq23vidVTQwF8v2o1rHvtdD4KJ5Mi41TXAfnih3XUf+fCTXdbAXKqweDuhcZg09/r7U/6zo76wjnt1cePPZe63/xG6bAVQ+Gz1J+HZz55ofDZV9g9kwyNll4Jfdzj9hUHO8AfBMvXQJewRj/LbzbmbBp5peov+DFhx7TWofvqxjreVKxDiDN89pC+WKy5BwZMcpB3dRVGuZ25ZrENLgoTX7W4PHPb1+OF4edP6xM44egcJLamk7vhvpZQqukJGRQZFtIMw8KIkC7OWzpCFIXN08= 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Syrus Akbary 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. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | 4 | include tox.ini 5 | include Makefile 6 | 7 | recursive-include flask_graphql *.py 8 | recursive-include tests *.py 9 | 10 | global-exclude *.py[co] __pycache__ 11 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | dev-setup: 2 | python pip install -e ".[test]" 3 | 4 | tests: 5 | py.test tests --cov=flask_graphql -vv -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flask-GraphQL 2 | 3 | Adds GraphQL support to your Flask application. 4 | 5 | [![travis][travis-image]][travis-url] 6 | [![pypi][pypi-image]][pypi-url] 7 | [![Anaconda-Server Badge][conda-image]][conda-url] 8 | [![coveralls][coveralls-image]][coveralls-url] 9 | 10 | [travis-image]: https://travis-ci.org/graphql-python/flask-graphql.svg?branch=master 11 | [travis-url]: https://travis-ci.org/graphql-python/flask-graphql 12 | [pypi-image]: https://img.shields.io/pypi/v/flask-graphql.svg?style=flat 13 | [pypi-url]: https://pypi.org/project/flask-graphql/ 14 | [coveralls-image]: https://coveralls.io/repos/graphql-python/flask-graphql/badge.svg?branch=master&service=github 15 | [coveralls-url]: https://coveralls.io/github/graphql-python/flask-graphql?branch=master 16 | [conda-image]: https://img.shields.io/conda/vn/conda-forge/flask-graphql.svg 17 | [conda-url]: https://anaconda.org/conda-forge/flask-graphql 18 | 19 | ## Usage 20 | 21 | Just use the `GraphQLView` view from `flask_graphql` 22 | 23 | ```python 24 | from flask import Flask 25 | from flask_graphql import GraphQLView 26 | 27 | from schema import schema 28 | 29 | app = Flask(__name__) 30 | 31 | app.add_url_rule('/graphql', view_func=GraphQLView.as_view( 32 | 'graphql', 33 | schema=schema, 34 | graphiql=True, 35 | )) 36 | 37 | # Optional, for adding batch query support (used in Apollo-Client) 38 | app.add_url_rule('/graphql/batch', view_func=GraphQLView.as_view( 39 | 'graphql', 40 | schema=schema, 41 | batch=True 42 | )) 43 | 44 | if __name__ == '__main__': 45 | app.run() 46 | ``` 47 | 48 | This will add `/graphql` endpoint to your app and enable the GraphiQL IDE. 49 | 50 | ### Special Note for Graphene v3 51 | 52 | If you are using the `Schema` type of [Graphene](https://github.com/graphql-python/graphene) library, be sure to use the `graphql_schema` attribute to pass as schema on the `GraphQLView` view. Otherwise, the `GraphQLSchema` from `graphql-core` is the way to go. 53 | 54 | More info at [Graphene v3 release notes](https://github.com/graphql-python/graphene/wiki/v3-release-notes#graphene-schema-no-longer-subclasses-graphqlschema-type) and [GraphQL-core 3 usage](https://github.com/graphql-python/graphql-core#usage). 55 | 56 | 57 | ### Supported options for GraphQLView 58 | 59 | * `schema`: The `GraphQLSchema` object that you want the view to execute when it gets a valid request. 60 | * `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`. 61 | * `root_value`: The `root_value` you want to provide to graphql `execute`. 62 | * `pretty`: Whether or not you want the response to be pretty printed JSON. 63 | * `graphiql`: If `True`, may present [GraphiQL](https://github.com/graphql/graphiql) when loaded directly from a browser (a useful tool for debugging and exploration). 64 | * `graphiql_version`: The graphiql version to load. Defaults to **"1.0.3"**. 65 | * `graphiql_template`: Inject a Jinja template string to customize GraphiQL. 66 | * `graphiql_html_title`: The graphiql title to display. Defaults to **"GraphiQL"**. 67 | * `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)) 68 | * `middleware`: A list of graphql [middlewares](http://docs.graphene-python.org/en/latest/execution/middleware/). 69 | * `encode`: the encoder to use for responses (sensibly defaults to `graphql_server.json_encode`). 70 | * `format_error`: the error formatter to use for responses (sensibly defaults to `graphql_server.default_format_error`. 71 | * `subscriptions`: The GraphiQL socket endpoint for using subscriptions in graphql-ws. 72 | * `headers`: An optional GraphQL string to use as the initial displayed request headers, if not provided, the stored headers will be used. 73 | * `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. 74 | * `header_editor_enabled`: An optional boolean which enables the header editor when true. Defaults to **false**. 75 | * `should_persist_headers`: An optional boolean which enables to persist headers to storage when true. Defaults to **false**. 76 | 77 | You can also subclass `GraphQLView` and overwrite `get_root_value(self, request)` to have a dynamic root value 78 | per request. 79 | 80 | ```python 81 | class UserRootValue(GraphQLView): 82 | def get_root_value(self, request): 83 | return request.user 84 | 85 | ``` 86 | 87 | ## Contributing 88 | Since v3, `flask-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). 89 | -------------------------------------------------------------------------------- /flask_graphql/__init__.py: -------------------------------------------------------------------------------- 1 | from graphql_server.flask.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 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | install_requires = [ 4 | "graphql-server[flask]>=3.0.0b1", 5 | ] 6 | 7 | tests_requires = [ 8 | "pytest>=5.4,<5.5", 9 | "pytest-cov>=2.8,<3", 10 | ] 11 | 12 | dev_requires = [ 13 | "flake8>=3.7,<4", 14 | "isort>=4,<5", 15 | "check-manifest>=0.40,<1", 16 | ] + tests_requires 17 | 18 | with open("README.md", encoding="utf-8") as readme_file: 19 | readme = readme_file.read() 20 | 21 | setup( 22 | name="Flask-GraphQL", 23 | version="2.0.1", 24 | description="Adds GraphQL support to your Flask application", 25 | long_description=readme, 26 | long_description_content_type="text/markdown", 27 | url="https://github.com/graphql-python/flask-graphql", 28 | download_url="https://github.com/graphql-python/flask-graphql/releases", 29 | author="Syrus Akbary", 30 | author_email="me@syrusakbary.com", 31 | license="MIT", 32 | classifiers=[ 33 | "Development Status :: 5 - Production/Stable", 34 | "Intended Audience :: Developers", 35 | "Topic :: Software Development :: Libraries", 36 | "Programming Language :: Python :: 3.6", 37 | "Programming Language :: Python :: 3.7", 38 | "Programming Language :: Python :: 3.8", 39 | "License :: OSI Approved :: MIT License", 40 | ], 41 | keywords="api graphql protocol rest flask", 42 | packages=find_packages(exclude=["tests"]), 43 | install_requires=install_requires, 44 | tests_require=tests_requires, 45 | extras_require={ 46 | 'test': tests_requires, 47 | 'dev': dev_requires, 48 | }, 49 | include_package_data=True, 50 | zip_safe=False, 51 | platforms="any", 52 | ) 53 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/graphql-python/flask-graphql/0d45561607950b68458925537f116298fc81b4d3/tests/__init__.py -------------------------------------------------------------------------------- /tests/app.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | 3 | from flask_graphql import GraphQLView 4 | from tests.schema import Schema 5 | 6 | 7 | def create_app(path="/graphql", **kwargs): 8 | app = Flask(__name__) 9 | app.debug = True 10 | app.add_url_rule( 11 | path, view_func=GraphQLView.as_view("graphql", schema=Schema, **kwargs) 12 | ) 13 | return app 14 | 15 | 16 | if __name__ == "__main__": 17 | app = create_app(graphiql=True) 18 | app.run() 19 | -------------------------------------------------------------------------------- /tests/schema.py: -------------------------------------------------------------------------------- 1 | from graphql.type.definition import (GraphQLArgument, GraphQLField, 2 | GraphQLNonNull, GraphQLObjectType) 3 | from graphql.type.scalars import GraphQLString 4 | from graphql.type.schema import GraphQLSchema 5 | 6 | 7 | def resolve_raises(*_): 8 | raise Exception("Throws!") 9 | 10 | 11 | QueryRootType = GraphQLObjectType( 12 | name="QueryRoot", 13 | fields={ 14 | "thrower": GraphQLField(GraphQLNonNull(GraphQLString), resolve=resolve_raises), 15 | "request": GraphQLField( 16 | GraphQLNonNull(GraphQLString), 17 | resolve=lambda obj, info: info.context["request"].args.get("q"), 18 | ), 19 | "context": GraphQLField( 20 | GraphQLObjectType( 21 | name="context", 22 | fields={ 23 | "session": GraphQLField(GraphQLString), 24 | "request": GraphQLField( 25 | GraphQLNonNull(GraphQLString), 26 | resolve=lambda obj, info: info.context["request"], 27 | ), 28 | }, 29 | ), 30 | resolve=lambda obj, info: info.context, 31 | ), 32 | "test": GraphQLField( 33 | type_=GraphQLString, 34 | args={"who": GraphQLArgument(GraphQLString)}, 35 | resolve=lambda obj, info, who="World": "Hello %s" % who, 36 | ), 37 | }, 38 | ) 39 | 40 | MutationRootType = GraphQLObjectType( 41 | name="MutationRoot", 42 | fields={ 43 | "writeTest": GraphQLField(type_=QueryRootType, resolve=lambda *_: QueryRootType) 44 | }, 45 | ) 46 | 47 | Schema = GraphQLSchema(QueryRootType, MutationRootType) 48 | -------------------------------------------------------------------------------- /tests/test_graphiqlview.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from flask import url_for 3 | 4 | from .app import create_app 5 | 6 | 7 | @pytest.fixture 8 | def app(): 9 | # import app factory pattern 10 | app = create_app(graphiql=True) 11 | 12 | # pushes an application context manually 13 | ctx = app.app_context() 14 | ctx.push() 15 | return app 16 | 17 | 18 | @pytest.fixture 19 | def client(app): 20 | return app.test_client() 21 | 22 | 23 | def test_graphiql_is_enabled(app, client): 24 | with app.test_request_context(): 25 | response = client.get( 26 | url_for("graphql", externals=False), headers={"Accept": "text/html"} 27 | ) 28 | assert response.status_code == 200 29 | 30 | 31 | def test_graphiql_renders_pretty(app, client): 32 | with app.test_request_context(): 33 | response = client.get( 34 | url_for("graphql", query="{test}"), headers={"Accept": "text/html"} 35 | ) 36 | assert response.status_code == 200 37 | pretty_response = ( 38 | "{\n" 39 | ' "data": {\n' 40 | ' "test": "Hello World"\n' 41 | " }\n" 42 | "}".replace('"', '\\"').replace("\n", "\\n") 43 | ) 44 | 45 | assert pretty_response in response.data.decode("utf-8") 46 | 47 | 48 | def test_graphiql_default_title(app, client): 49 | with app.test_request_context(): 50 | response = client.get(url_for("graphql"), headers={"Accept": "text/html"}) 51 | assert "GraphiQL" in response.data.decode("utf-8") 52 | 53 | 54 | @pytest.mark.parametrize( 55 | "app", [create_app(graphiql=True, graphiql_html_title="Awesome")] 56 | ) 57 | def test_graphiql_custom_title(app, client): 58 | with app.test_request_context(): 59 | response = client.get(url_for("graphql"), headers={"Accept": "text/html"}) 60 | assert "Awesome" in response.data.decode("utf-8") 61 | -------------------------------------------------------------------------------- /tests/test_graphqlview.py: -------------------------------------------------------------------------------- 1 | import json 2 | from io import StringIO 3 | from urllib.parse import urlencode 4 | 5 | import pytest 6 | from flask import url_for 7 | 8 | from .app import create_app 9 | 10 | 11 | @pytest.fixture 12 | def app(request): 13 | # import app factory pattern 14 | app = create_app() 15 | 16 | # pushes an application context manually 17 | ctx = app.app_context() 18 | ctx.push() 19 | return app 20 | 21 | 22 | @pytest.fixture 23 | def client(app): 24 | return app.test_client() 25 | 26 | 27 | def url_string(app, **url_params): 28 | with app.test_request_context(): 29 | string = url_for("graphql") 30 | 31 | if url_params: 32 | string += "?" + urlencode(url_params) 33 | 34 | return string 35 | 36 | 37 | def response_json(response): 38 | return json.loads(response.data.decode()) 39 | 40 | 41 | def json_dump_kwarg(**kwargs): 42 | return json.dumps(kwargs) 43 | 44 | 45 | def json_dump_kwarg_list(**kwargs): 46 | return json.dumps([kwargs]) 47 | 48 | 49 | def test_allows_get_with_query_param(app, client): 50 | response = client.get(url_string(app, query="{test}")) 51 | 52 | assert response.status_code == 200 53 | assert response_json(response) == {"data": {"test": "Hello World"}} 54 | 55 | 56 | def test_allows_get_with_variable_values(app, client): 57 | response = client.get( 58 | url_string( 59 | app, 60 | query="query helloWho($who: String){ test(who: $who) }", 61 | variables=json.dumps({"who": "Dolly"}), 62 | ) 63 | ) 64 | 65 | assert response.status_code == 200 66 | assert response_json(response) == {"data": {"test": "Hello Dolly"}} 67 | 68 | 69 | def test_allows_get_with_operation_name(app, client): 70 | response = client.get( 71 | url_string( 72 | app, 73 | query=""" 74 | query helloYou { test(who: "You"), ...shared } 75 | query helloWorld { test(who: "World"), ...shared } 76 | query helloDolly { test(who: "Dolly"), ...shared } 77 | fragment shared on QueryRoot { 78 | shared: test(who: "Everyone") 79 | } 80 | """, 81 | operationName="helloWorld", 82 | ) 83 | ) 84 | 85 | assert response.status_code == 200 86 | assert response_json(response) == { 87 | "data": {"test": "Hello World", "shared": "Hello Everyone"} 88 | } 89 | 90 | 91 | def test_reports_validation_errors(app, client): 92 | response = client.get(url_string(app, query="{ test, unknownOne, unknownTwo }")) 93 | 94 | assert response.status_code == 400 95 | assert response_json(response) == { 96 | "errors": [ 97 | { 98 | "message": "Cannot query field 'unknownOne' on type 'QueryRoot'.", 99 | "locations": [{"line": 1, "column": 9}], 100 | "path": None, 101 | }, 102 | { 103 | "message": "Cannot query field 'unknownTwo' on type 'QueryRoot'.", 104 | "locations": [{"line": 1, "column": 21}], 105 | "path": None, 106 | }, 107 | ] 108 | } 109 | 110 | 111 | def test_errors_when_missing_operation_name(app, client): 112 | response = client.get( 113 | url_string( 114 | app, 115 | query=""" 116 | query TestQuery { test } 117 | mutation TestMutation { writeTest { test } } 118 | """, 119 | ) 120 | ) 121 | 122 | assert response.status_code == 400 123 | assert response_json(response) == { 124 | "errors": [ 125 | { 126 | "message": "Must provide operation name if query contains multiple operations.", # noqa: E501 127 | "locations": None, 128 | "path": None, 129 | } 130 | ] 131 | } 132 | 133 | 134 | def test_errors_when_sending_a_mutation_via_get(app, client): 135 | response = client.get( 136 | url_string( 137 | app, 138 | query=""" 139 | mutation TestMutation { writeTest { test } } 140 | """, 141 | ) 142 | ) 143 | assert response.status_code == 405 144 | assert response_json(response) == { 145 | "errors": [ 146 | { 147 | "message": "Can only perform a mutation operation from a POST request.", 148 | "locations": None, 149 | "path": None, 150 | } 151 | ] 152 | } 153 | 154 | 155 | def test_errors_when_selecting_a_mutation_within_a_get(app, client): 156 | response = client.get( 157 | url_string( 158 | app, 159 | query=""" 160 | query TestQuery { test } 161 | mutation TestMutation { writeTest { test } } 162 | """, 163 | operationName="TestMutation", 164 | ) 165 | ) 166 | 167 | assert response.status_code == 405 168 | assert response_json(response) == { 169 | "errors": [ 170 | { 171 | "message": "Can only perform a mutation operation from a POST request.", 172 | "locations": None, 173 | "path": None, 174 | } 175 | ] 176 | } 177 | 178 | 179 | def test_allows_mutation_to_exist_within_a_get(app, client): 180 | response = client.get( 181 | url_string( 182 | app, 183 | query=""" 184 | query TestQuery { test } 185 | mutation TestMutation { writeTest { test } } 186 | """, 187 | operationName="TestQuery", 188 | ) 189 | ) 190 | 191 | assert response.status_code == 200 192 | assert response_json(response) == {"data": {"test": "Hello World"}} 193 | 194 | 195 | def test_allows_post_with_json_encoding(app, client): 196 | response = client.post( 197 | url_string(app), 198 | data=json_dump_kwarg(query="{test}"), 199 | content_type="application/json", 200 | ) 201 | 202 | assert response.status_code == 200 203 | assert response_json(response) == {"data": {"test": "Hello World"}} 204 | 205 | 206 | def test_allows_sending_a_mutation_via_post(app, client): 207 | response = client.post( 208 | url_string(app), 209 | data=json_dump_kwarg(query="mutation TestMutation { writeTest { test } }"), 210 | content_type="application/json", 211 | ) 212 | 213 | assert response.status_code == 200 214 | assert response_json(response) == {"data": {"writeTest": {"test": "Hello World"}}} 215 | 216 | 217 | def test_allows_post_with_url_encoding(app, client): 218 | response = client.post( 219 | url_string(app), 220 | data=urlencode(dict(query="{test}")), 221 | content_type="application/x-www-form-urlencoded", 222 | ) 223 | 224 | assert response.status_code == 200 225 | assert response_json(response) == {"data": {"test": "Hello World"}} 226 | 227 | 228 | def test_supports_post_json_query_with_string_variables(app, client): 229 | response = client.post( 230 | url_string(app), 231 | data=json_dump_kwarg( 232 | query="query helloWho($who: String){ test(who: $who) }", 233 | variables=json.dumps({"who": "Dolly"}), 234 | ), 235 | content_type="application/json", 236 | ) 237 | 238 | assert response.status_code == 200 239 | assert response_json(response) == {"data": {"test": "Hello Dolly"}} 240 | 241 | 242 | def test_supports_post_json_query_with_json_variables(app, client): 243 | response = client.post( 244 | url_string(app), 245 | data=json_dump_kwarg( 246 | query="query helloWho($who: String){ test(who: $who) }", 247 | variables={"who": "Dolly"}, 248 | ), 249 | content_type="application/json", 250 | ) 251 | 252 | assert response.status_code == 200 253 | assert response_json(response) == {"data": {"test": "Hello Dolly"}} 254 | 255 | 256 | def test_supports_post_url_encoded_query_with_string_variables(app, client): 257 | response = client.post( 258 | url_string(app), 259 | data=urlencode( 260 | dict( 261 | query="query helloWho($who: String){ test(who: $who) }", 262 | variables=json.dumps({"who": "Dolly"}), 263 | ) 264 | ), 265 | content_type="application/x-www-form-urlencoded", 266 | ) 267 | 268 | assert response.status_code == 200 269 | assert response_json(response) == {"data": {"test": "Hello Dolly"}} 270 | 271 | 272 | def test_supports_post_json_quey_with_get_variable_values(app, client): 273 | response = client.post( 274 | url_string(app, variables=json.dumps({"who": "Dolly"})), 275 | data=json_dump_kwarg(query="query helloWho($who: String){ test(who: $who) }",), 276 | content_type="application/json", 277 | ) 278 | 279 | assert response.status_code == 200 280 | assert response_json(response) == {"data": {"test": "Hello Dolly"}} 281 | 282 | 283 | def test_post_url_encoded_query_with_get_variable_values(app, client): 284 | response = client.post( 285 | url_string(app, variables=json.dumps({"who": "Dolly"})), 286 | data=urlencode(dict(query="query helloWho($who: String){ test(who: $who) }",)), 287 | content_type="application/x-www-form-urlencoded", 288 | ) 289 | 290 | assert response.status_code == 200 291 | assert response_json(response) == {"data": {"test": "Hello Dolly"}} 292 | 293 | 294 | def test_supports_post_raw_text_query_with_get_variable_values(app, client): 295 | response = client.post( 296 | url_string(app, variables=json.dumps({"who": "Dolly"})), 297 | data="query helloWho($who: String){ test(who: $who) }", 298 | content_type="application/graphql", 299 | ) 300 | 301 | assert response.status_code == 200 302 | assert response_json(response) == {"data": {"test": "Hello Dolly"}} 303 | 304 | 305 | def test_allows_post_with_operation_name(app, client): 306 | response = client.post( 307 | url_string(app), 308 | data=json_dump_kwarg( 309 | query=""" 310 | query helloYou { test(who: "You"), ...shared } 311 | query helloWorld { test(who: "World"), ...shared } 312 | query helloDolly { test(who: "Dolly"), ...shared } 313 | fragment shared on QueryRoot { 314 | shared: test(who: "Everyone") 315 | } 316 | """, 317 | operationName="helloWorld", 318 | ), 319 | content_type="application/json", 320 | ) 321 | 322 | assert response.status_code == 200 323 | assert response_json(response) == { 324 | "data": {"test": "Hello World", "shared": "Hello Everyone"} 325 | } 326 | 327 | 328 | def test_allows_post_with_get_operation_name(app, client): 329 | response = client.post( 330 | url_string(app, operationName="helloWorld"), 331 | data=""" 332 | query helloYou { test(who: "You"), ...shared } 333 | query helloWorld { test(who: "World"), ...shared } 334 | query helloDolly { test(who: "Dolly"), ...shared } 335 | fragment shared on QueryRoot { 336 | shared: test(who: "Everyone") 337 | } 338 | """, 339 | content_type="application/graphql", 340 | ) 341 | 342 | assert response.status_code == 200 343 | assert response_json(response) == { 344 | "data": {"test": "Hello World", "shared": "Hello Everyone"} 345 | } 346 | 347 | 348 | @pytest.mark.parametrize("app", [create_app(pretty=True)]) 349 | def test_supports_pretty_printing(app, client): 350 | response = client.get(url_string(app, query="{test}")) 351 | 352 | assert response.data.decode() == ( 353 | "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" 354 | ) 355 | 356 | 357 | @pytest.mark.parametrize("app", [create_app(pretty=False)]) 358 | def test_not_pretty_by_default(app, client): 359 | response = client.get(url_string(app, query="{test}")) 360 | 361 | assert response.data.decode() == '{"data":{"test":"Hello World"}}' 362 | 363 | 364 | def test_supports_pretty_printing_by_request(app, client): 365 | response = client.get(url_string(app, query="{test}", pretty="1")) 366 | 367 | assert response.data.decode() == ( 368 | "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" 369 | ) 370 | 371 | 372 | def test_handles_field_errors_caught_by_graphql(app, client): 373 | response = client.get(url_string(app, query="{thrower}")) 374 | assert response.status_code == 200 375 | assert response_json(response) == { 376 | "errors": [ 377 | { 378 | "locations": [{"column": 2, "line": 1}], 379 | "path": ["thrower"], 380 | "message": "Throws!", 381 | } 382 | ], 383 | "data": None, 384 | } 385 | 386 | 387 | def test_handles_syntax_errors_caught_by_graphql(app, client): 388 | response = client.get(url_string(app, query="syntaxerror")) 389 | assert response.status_code == 400 390 | assert response_json(response) == { 391 | "errors": [ 392 | { 393 | "locations": [{"column": 1, "line": 1}], 394 | "message": "Syntax Error: Unexpected Name 'syntaxerror'.", 395 | "path": None, 396 | } 397 | ] 398 | } 399 | 400 | 401 | def test_handles_errors_caused_by_a_lack_of_query(app, client): 402 | response = client.get(url_string(app)) 403 | 404 | assert response.status_code == 400 405 | assert response_json(response) == { 406 | "errors": [ 407 | {"message": "Must provide query string.", "locations": None, "path": None} 408 | ] 409 | } 410 | 411 | 412 | def test_handles_batch_correctly_if_is_disabled(app, client): 413 | response = client.post(url_string(app), data="[]", content_type="application/json") 414 | 415 | assert response.status_code == 400 416 | assert response_json(response) == { 417 | "errors": [ 418 | { 419 | "message": "Batch GraphQL requests are not enabled.", 420 | "locations": None, 421 | "path": None, 422 | } 423 | ] 424 | } 425 | 426 | 427 | def test_handles_incomplete_json_bodies(app, client): 428 | response = client.post( 429 | url_string(app), data='{"query":', content_type="application/json" 430 | ) 431 | 432 | assert response.status_code == 400 433 | assert response_json(response) == { 434 | "errors": [ 435 | {"message": "POST body sent invalid JSON.", "locations": None, "path": None} 436 | ] 437 | } 438 | 439 | 440 | def test_handles_plain_post_text(app, client): 441 | response = client.post( 442 | url_string(app, variables=json.dumps({"who": "Dolly"})), 443 | data="query helloWho($who: String){ test(who: $who) }", 444 | content_type="text/plain", 445 | ) 446 | assert response.status_code == 400 447 | assert response_json(response) == { 448 | "errors": [ 449 | {"message": "Must provide query string.", "locations": None, "path": None} 450 | ] 451 | } 452 | 453 | 454 | def test_handles_poorly_formed_variables(app, client): 455 | response = client.get( 456 | url_string( 457 | app, 458 | query="query helloWho($who: String){ test(who: $who) }", 459 | variables="who:You", 460 | ) 461 | ) 462 | assert response.status_code == 400 463 | assert response_json(response) == { 464 | "errors": [ 465 | {"message": "Variables are invalid JSON.", "locations": None, "path": None} 466 | ] 467 | } 468 | 469 | 470 | def test_handles_unsupported_http_methods(app, client): 471 | response = client.put(url_string(app, query="{test}")) 472 | assert response.status_code == 405 473 | assert response.headers["Allow"] in ["GET, POST", "HEAD, GET, POST, OPTIONS"] 474 | assert response_json(response) == { 475 | "errors": [ 476 | { 477 | "message": "GraphQL only supports GET and POST requests.", 478 | "locations": None, 479 | "path": None, 480 | } 481 | ] 482 | } 483 | 484 | 485 | def test_passes_request_into_request_context(app, client): 486 | response = client.get(url_string(app, query="{request}", q="testing")) 487 | 488 | assert response.status_code == 200 489 | assert response_json(response) == {"data": {"request": "testing"}} 490 | 491 | 492 | @pytest.mark.parametrize("app", [create_app(context={"session": "CUSTOM CONTEXT"})]) 493 | def test_passes_custom_context_into_context(app, client): 494 | response = client.get(url_string(app, query="{context { session request }}")) 495 | 496 | assert response.status_code == 200 497 | res = response_json(response) 498 | assert "data" in res 499 | assert "session" in res["data"]["context"] 500 | assert "request" in res["data"]["context"] 501 | assert "CUSTOM CONTEXT" in res["data"]["context"]["session"] 502 | assert "Request" in res["data"]["context"]["request"] 503 | 504 | 505 | @pytest.mark.parametrize("app", [create_app(context="CUSTOM CONTEXT")]) 506 | def test_context_remapped_if_not_mapping(app, client): 507 | response = client.get(url_string(app, query="{context { session request }}")) 508 | 509 | assert response.status_code == 200 510 | res = response_json(response) 511 | assert "data" in res 512 | assert "session" in res["data"]["context"] 513 | assert "request" in res["data"]["context"] 514 | assert "CUSTOM CONTEXT" not in res["data"]["context"]["request"] 515 | assert "Request" in res["data"]["context"]["request"] 516 | 517 | 518 | def test_post_multipart_data(app, client): 519 | query = "mutation TestMutation { writeTest { test } }" 520 | response = client.post( 521 | url_string(app), 522 | data={"query": query, "file": (StringIO(), "text1.txt")}, 523 | content_type="multipart/form-data", 524 | ) 525 | 526 | assert response.status_code == 200 527 | assert response_json(response) == { 528 | "data": {u"writeTest": {u"test": u"Hello World"}} 529 | } 530 | 531 | 532 | @pytest.mark.parametrize("app", [create_app(batch=True)]) 533 | def test_batch_allows_post_with_json_encoding(app, client): 534 | response = client.post( 535 | url_string(app), 536 | data=json_dump_kwarg_list( 537 | # id=1, 538 | query="{test}" 539 | ), 540 | content_type="application/json", 541 | ) 542 | 543 | assert response.status_code == 200 544 | assert response_json(response) == [ 545 | { 546 | # 'id': 1, 547 | "data": {"test": "Hello World"} 548 | } 549 | ] 550 | 551 | 552 | @pytest.mark.parametrize("app", [create_app(batch=True)]) 553 | def test_batch_supports_post_json_query_with_json_variables(app, client): 554 | response = client.post( 555 | url_string(app), 556 | data=json_dump_kwarg_list( 557 | # id=1, 558 | query="query helloWho($who: String){ test(who: $who) }", 559 | variables={"who": "Dolly"}, 560 | ), 561 | content_type="application/json", 562 | ) 563 | 564 | assert response.status_code == 200 565 | assert response_json(response) == [ 566 | { 567 | # 'id': 1, 568 | "data": {"test": "Hello Dolly"} 569 | } 570 | ] 571 | 572 | 573 | @pytest.mark.parametrize("app", [create_app(batch=True)]) 574 | def test_batch_allows_post_with_operation_name(app, client): 575 | response = client.post( 576 | url_string(app), 577 | data=json_dump_kwarg_list( 578 | # id=1, 579 | query=""" 580 | query helloYou { test(who: "You"), ...shared } 581 | query helloWorld { test(who: "World"), ...shared } 582 | query helloDolly { test(who: "Dolly"), ...shared } 583 | fragment shared on QueryRoot { 584 | shared: test(who: "Everyone") 585 | } 586 | """, 587 | operationName="helloWorld", 588 | ), 589 | content_type="application/json", 590 | ) 591 | 592 | assert response.status_code == 200 593 | assert response_json(response) == [ 594 | { 595 | # 'id': 1, 596 | "data": {"test": "Hello World", "shared": "Hello Everyone"} 597 | } 598 | ] 599 | -------------------------------------------------------------------------------- /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=flask_graphql {posargs} 15 | 16 | [testenv:flake8] 17 | basepython=python3.8 18 | deps = -e.[dev] 19 | commands = 20 | flake8 setup.py flask_graphql tests 21 | 22 | [testenv:import-order] 23 | basepython=python3.8 24 | deps = -e.[dev] 25 | commands = 26 | isort -rc flask_graphql/ tests/ 27 | 28 | [testenv:manifest] 29 | basepython = python3.8 30 | deps = -e.[dev] 31 | commands = 32 | check-manifest -v --------------------------------------------------------------------------------