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