├── .gitignore ├── LICENSE ├── README.md ├── bootstrap-buildout.py ├── buildout.cfg ├── graphql_wsgi ├── __init__.py └── main.py ├── setup.py └── tests └── test_graphql_wsgi.py /.gitignore: -------------------------------------------------------------------------------- 1 | bin 2 | src 3 | .mr.developer.cfg 4 | develop-eggs 5 | .installed.cfg 6 | *.egg-info 7 | .cache 8 | __pycache__ 9 | *.pyc 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Martijn Faassen 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # graphql_wsgi 2 | 3 | Port of [express-graphql](https://github.com/graphql/express-graphql) 4 | to Python and WSGI. Uses 5 | [graphql-core](https://github.com/graphql-python/graphql-core). 6 | 7 | For a demo of how this can be used to create a GraphQL server (with Relay 8 | support) in Python, see [relaypy](https://github.com/faassen/relaypy). 9 | 10 | ## Credits 11 | 12 | This code was ported by Martijn Faassen. 13 | 14 | ## License 15 | 16 | [MIT License](https://github.com/faassen/graphql_wsgi/blob/master/LICENSE) 17 | 18 | ## How to execute the tests 19 | 20 | python2.7 bootstrap-buildout.py 21 | bin/buildout 22 | bin/py.test tests -vv --pdb 23 | -------------------------------------------------------------------------------- /bootstrap-buildout.py: -------------------------------------------------------------------------------- 1 | ############################################################################## 2 | # 3 | # Copyright (c) 2006 Zope Foundation and Contributors. 4 | # All Rights Reserved. 5 | # 6 | # This software is subject to the provisions of the Zope Public License, 7 | # Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. 8 | # THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED 9 | # WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 10 | # WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS 11 | # FOR A PARTICULAR PURPOSE. 12 | # 13 | ############################################################################## 14 | """Bootstrap a buildout-based project 15 | 16 | Simply run this script in a directory containing a buildout.cfg. 17 | The script accepts buildout command-line options, so you can 18 | use the -c option to specify an alternate configuration file. 19 | """ 20 | 21 | import os 22 | import shutil 23 | import sys 24 | import tempfile 25 | 26 | from optparse import OptionParser 27 | 28 | __version__ = '2015-07-01' 29 | # See zc.buildout's changelog if this version is up to date. 30 | 31 | tmpeggs = tempfile.mkdtemp(prefix='bootstrap-') 32 | 33 | usage = '''\ 34 | [DESIRED PYTHON FOR BUILDOUT] bootstrap.py [options] 35 | 36 | Bootstraps a buildout-based project. 37 | 38 | Simply run this script in a directory containing a buildout.cfg, using the 39 | Python that you want bin/buildout to use. 40 | 41 | Note that by using --find-links to point to local resources, you can keep 42 | this script from going over the network. 43 | ''' 44 | 45 | parser = OptionParser(usage=usage) 46 | parser.add_option("--version", 47 | action="store_true", default=False, 48 | help=("Return bootstrap.py version.")) 49 | parser.add_option("-t", "--accept-buildout-test-releases", 50 | dest='accept_buildout_test_releases', 51 | action="store_true", default=False, 52 | help=("Normally, if you do not specify a --version, the " 53 | "bootstrap script and buildout gets the newest " 54 | "*final* versions of zc.buildout and its recipes and " 55 | "extensions for you. If you use this flag, " 56 | "bootstrap and buildout will get the newest releases " 57 | "even if they are alphas or betas.")) 58 | parser.add_option("-c", "--config-file", 59 | help=("Specify the path to the buildout configuration " 60 | "file to be used.")) 61 | parser.add_option("-f", "--find-links", 62 | help=("Specify a URL to search for buildout releases")) 63 | parser.add_option("--allow-site-packages", 64 | action="store_true", default=False, 65 | help=("Let bootstrap.py use existing site packages")) 66 | parser.add_option("--buildout-version", 67 | help="Use a specific zc.buildout version") 68 | parser.add_option("--setuptools-version", 69 | help="Use a specific setuptools version") 70 | parser.add_option("--setuptools-to-dir", 71 | help=("Allow for re-use of existing directory of " 72 | "setuptools versions")) 73 | 74 | options, args = parser.parse_args() 75 | if options.version: 76 | print("bootstrap.py version %s" % __version__) 77 | sys.exit(0) 78 | 79 | 80 | ###################################################################### 81 | # load/install setuptools 82 | 83 | try: 84 | from urllib.request import urlopen 85 | except ImportError: 86 | from urllib2 import urlopen 87 | 88 | ez = {} 89 | if os.path.exists('ez_setup.py'): 90 | exec(open('ez_setup.py').read(), ez) 91 | else: 92 | exec(urlopen('https://bootstrap.pypa.io/ez_setup.py').read(), ez) 93 | 94 | if not options.allow_site_packages: 95 | # ez_setup imports site, which adds site packages 96 | # this will remove them from the path to ensure that incompatible versions 97 | # of setuptools are not in the path 98 | import site 99 | # inside a virtualenv, there is no 'getsitepackages'. 100 | # We can't remove these reliably 101 | if hasattr(site, 'getsitepackages'): 102 | for sitepackage_path in site.getsitepackages(): 103 | # Strip all site-packages directories from sys.path that 104 | # are not sys.prefix; this is because on Windows 105 | # sys.prefix is a site-package directory. 106 | if sitepackage_path != sys.prefix: 107 | sys.path[:] = [x for x in sys.path 108 | if sitepackage_path not in x] 109 | 110 | setup_args = dict(to_dir=tmpeggs, download_delay=0) 111 | 112 | if options.setuptools_version is not None: 113 | setup_args['version'] = options.setuptools_version 114 | if options.setuptools_to_dir is not None: 115 | setup_args['to_dir'] = options.setuptools_to_dir 116 | 117 | ez['use_setuptools'](**setup_args) 118 | import setuptools 119 | import pkg_resources 120 | 121 | # This does not (always?) update the default working set. We will 122 | # do it. 123 | for path in sys.path: 124 | if path not in pkg_resources.working_set.entries: 125 | pkg_resources.working_set.add_entry(path) 126 | 127 | ###################################################################### 128 | # Install buildout 129 | 130 | ws = pkg_resources.working_set 131 | 132 | setuptools_path = ws.find( 133 | pkg_resources.Requirement.parse('setuptools')).location 134 | 135 | # Fix sys.path here as easy_install.pth added before PYTHONPATH 136 | cmd = [sys.executable, '-c', 137 | 'import sys; sys.path[0:0] = [%r]; ' % setuptools_path + 138 | 'from setuptools.command.easy_install import main; main()', 139 | '-mZqNxd', tmpeggs] 140 | 141 | find_links = os.environ.get( 142 | 'bootstrap-testing-find-links', 143 | options.find_links or 144 | ('http://downloads.buildout.org/' 145 | if options.accept_buildout_test_releases else None) 146 | ) 147 | if find_links: 148 | cmd.extend(['-f', find_links]) 149 | 150 | requirement = 'zc.buildout' 151 | version = options.buildout_version 152 | if version is None and not options.accept_buildout_test_releases: 153 | # Figure out the most recent final version of zc.buildout. 154 | import setuptools.package_index 155 | _final_parts = '*final-', '*final' 156 | 157 | def _final_version(parsed_version): 158 | try: 159 | return not parsed_version.is_prerelease 160 | except AttributeError: 161 | # Older setuptools 162 | for part in parsed_version: 163 | if (part[:1] == '*') and (part not in _final_parts): 164 | return False 165 | return True 166 | 167 | index = setuptools.package_index.PackageIndex( 168 | search_path=[setuptools_path]) 169 | if find_links: 170 | index.add_find_links((find_links,)) 171 | req = pkg_resources.Requirement.parse(requirement) 172 | if index.obtain(req) is not None: 173 | best = [] 174 | bestv = None 175 | for dist in index[req.project_name]: 176 | distv = dist.parsed_version 177 | if _final_version(distv): 178 | if bestv is None or distv > bestv: 179 | best = [dist] 180 | bestv = distv 181 | elif distv == bestv: 182 | best.append(dist) 183 | if best: 184 | best.sort() 185 | version = best[-1].version 186 | if version: 187 | requirement = '=='.join((requirement, version)) 188 | cmd.append(requirement) 189 | 190 | import subprocess 191 | if subprocess.call(cmd) != 0: 192 | raise Exception( 193 | "Failed to execute command:\n%s" % repr(cmd)[1:-1]) 194 | 195 | ###################################################################### 196 | # Import and run buildout 197 | 198 | ws.add_entry(tmpeggs) 199 | ws.require(requirement) 200 | import zc.buildout.buildout 201 | 202 | if not [a for a in args if '=' not in a]: 203 | args.append('bootstrap') 204 | 205 | # if -c was provided, we push it back into args for buildout' main function 206 | if options.config_file is not None: 207 | args[0:0] = ['-c', options.config_file] 208 | 209 | zc.buildout.buildout.main(args) 210 | shutil.rmtree(tmpeggs) 211 | -------------------------------------------------------------------------------- /buildout.cfg: -------------------------------------------------------------------------------- 1 | [buildout] 2 | develop = . 3 | parts = devpython scripts 4 | versions = versions 5 | show-picked-versions = false 6 | extensions = mr.developer 7 | auto-checkout = graphql-core 8 | 9 | [versions] 10 | pyflakes = 1.0.0 11 | 12 | [sources] 13 | graphql-core = git git@github.com:graphql-python/graphql-core.git 14 | 15 | [devpython] 16 | recipe = zc.recipe.egg 17 | interpreter = devpython 18 | eggs = graphql-wsgi 19 | # pyflakes 20 | # flake8 21 | # radon 22 | 23 | [scripts] 24 | recipe = zc.recipe.egg:scripts 25 | eggs = graphql-wsgi [test] 26 | pytest 27 | -------------------------------------------------------------------------------- /graphql_wsgi/__init__.py: -------------------------------------------------------------------------------- 1 | from .main import graphql_wsgi, graphql_wsgi_dynamic 2 | -------------------------------------------------------------------------------- /graphql_wsgi/main.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import six 4 | from webob.dec import wsgify 5 | from webob.response import Response 6 | 7 | 8 | from graphql import graphql 9 | from graphql.error import GraphQLError, format_error as format_graphql_error 10 | 11 | 12 | def graphql_wsgi_dynamic(get_options): 13 | @wsgify 14 | def handle(request): 15 | schema, root_value, pretty, middleware = get_options(request) 16 | 17 | if request.method != 'GET' and request.method != 'POST': 18 | return error_response( 19 | Error( 20 | 'GraphQL only supports GET and POST requests.', 21 | status=405, 22 | headers={'Allow': 'GET, POST'} 23 | ), 24 | pretty) 25 | 26 | try: 27 | data = parse_body(request) 28 | except Error as e: 29 | return error_response(e, pretty) 30 | 31 | try: 32 | query, variables, operation_name = get_graphql_params( 33 | request, data) 34 | except Error as e: 35 | return error_response(e, pretty) 36 | 37 | context_value = request 38 | result = graphql(schema, query, root_value, 39 | context_value, 40 | variables, 41 | operation_name, 42 | middleware=middleware) 43 | 44 | if result.invalid: 45 | status = 400 46 | else: 47 | status = 200 48 | 49 | d = {'data': result.data} 50 | if result.errors: 51 | d['errors'] = [format_error(error) for error in result.errors] 52 | 53 | return Response(status=status, 54 | content_type='application/json', 55 | body=json_dump(d, pretty).encode('utf8')) 56 | return handle 57 | 58 | 59 | def format_error(error): 60 | if isinstance(error, GraphQLError): 61 | return format_graphql_error(error) 62 | 63 | return {'message': '{}: {}'.format( 64 | error.__class__.__name__, six.text_type(error))} 65 | 66 | 67 | def graphql_wsgi(schema, root_value=None, pretty=None, middleware=None): 68 | def get_options(request): 69 | return schema, root_value, pretty, middleware 70 | 71 | return graphql_wsgi_dynamic(get_options) 72 | 73 | 74 | def json_dump(d, pretty): 75 | if not pretty: 76 | return json.dumps(d, separators=(',', ':')) 77 | return json.dumps(d, sort_keys=True, 78 | indent=2, separators=(',', ': ')) 79 | 80 | 81 | def parse_body(request): 82 | if request.content_type is None: 83 | return {} 84 | 85 | if request.content_type == 'application/graphql': 86 | try: 87 | return {'query': request.text} 88 | except LookupError: 89 | raise Error('Unsupported charset "%s".' % request.charset.upper(), 90 | status=415) 91 | elif request.content_type == 'application/json': 92 | try: 93 | return request.json 94 | except ValueError: 95 | raise Error('POST body sent invalid JSON.') 96 | elif request.content_type == 'application/x-www-form-urlencoded': 97 | return request.POST 98 | elif request.content_type == 'multipart/form-data': # support for apollo-upload-client 99 | return json.loads(request.POST['operations']) 100 | 101 | return {} 102 | 103 | 104 | def get_graphql_params(request, data): 105 | query = request.GET.get('query') or data.get('query') 106 | 107 | if query is None: 108 | raise Error('Must provide query string.') 109 | 110 | variables = request.GET.get('variables') or data.get('variables') 111 | 112 | if variables is not None and isinstance(variables, six.text_type): 113 | try: 114 | variables = json.loads(variables) 115 | except ValueError: 116 | raise Error('Variables are invalid JSON.') 117 | 118 | operation_name = (request.GET.get('operationName') or 119 | data.get('operationName')) 120 | 121 | for key, value in request.POST.items(): # support for apollo-upload-client 122 | if key.startswith('variables.'): 123 | variables[key[10:]] = key 124 | 125 | return query, variables, operation_name 126 | 127 | 128 | def error_response(e, pretty): 129 | d = { 130 | 'errors': [{'message': six.text_type(e)}] 131 | } 132 | response = Response(status=e.status, 133 | content_type='application/json', 134 | body=json_dump(d, pretty).encode('utf8')) 135 | if e.headers: 136 | response.headers.update(e.headers) 137 | return response 138 | 139 | 140 | class Error(Exception): 141 | def __init__(self, message, status=400, headers=None): 142 | super(Error, self).__init__(message) 143 | self.status = status 144 | self.headers = headers 145 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | tests_require = [ 4 | 'pytest >= 2.0', 5 | 'pytest-cov', 6 | 'pytest-remove-stale-bytecode', 7 | 'webtest' 8 | ] 9 | 10 | 11 | setup( 12 | name='graphql-wsgi', 13 | version='0.1.dev0', 14 | description="GraphQL server for Python WSGI", 15 | author="Martijn Faassen", 16 | author_email="faassen@startifact.com", 17 | license="BSD", 18 | packages=find_packages(), 19 | include_package_data=True, 20 | zip_safe=False, 21 | exclude=['tests'], 22 | install_requires=[ 23 | 'setuptools', 24 | 'graphql-core', 25 | 'webob', 26 | 'six', 27 | ], 28 | tests_require=tests_require, 29 | extras_require=dict( 30 | test=tests_require, 31 | ) 32 | ) 33 | -------------------------------------------------------------------------------- /tests/test_graphql_wsgi.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import json 3 | from webtest import TestApp as Client 4 | from graphql_wsgi import graphql_wsgi, graphql_wsgi_dynamic 5 | 6 | from graphql.type import ( 7 | GraphQLObjectType, 8 | GraphQLField, 9 | GraphQLArgument, 10 | GraphQLNonNull, 11 | GraphQLSchema, 12 | GraphQLString, 13 | ) 14 | 15 | 16 | def raises(*_): 17 | raise Exception("Throws!") 18 | 19 | 20 | def resolver(root, args, *_): 21 | return 'Hello ' + args.get('who', 'World') 22 | 23 | 24 | TestSchema = GraphQLSchema( 25 | query=GraphQLObjectType( 26 | 'Root', 27 | fields=lambda: { 28 | 'test': GraphQLField( 29 | GraphQLString, 30 | args={ 31 | 'who': GraphQLArgument( 32 | type=GraphQLString 33 | ) 34 | }, 35 | resolver=resolver 36 | ), 37 | 'thrower': GraphQLField( 38 | GraphQLNonNull(GraphQLString), 39 | resolver=raises 40 | ) 41 | } 42 | ) 43 | ) 44 | 45 | 46 | def test_GET_functionality_allows_GET_with_query_param(): 47 | wsgi = graphql_wsgi(TestSchema) 48 | 49 | c = Client(wsgi) 50 | response = c.get('/', {'query': '{test}'}) 51 | 52 | assert response.json == { 53 | 'data': { 54 | 'test': 'Hello World' 55 | } 56 | } 57 | 58 | 59 | def test_GET_functionality_allows_GET_with_variable_values(): 60 | wsgi = graphql_wsgi(TestSchema) 61 | 62 | c = Client(wsgi) 63 | response = c.get('/', { 64 | 'query': 'query helloWho($who: String){ test(who: $who) }', 65 | 'variables': json.dumps({'who': 'Dolly'}) 66 | }) 67 | 68 | assert response.json == { 69 | 'data': { 70 | 'test': 'Hello Dolly' 71 | } 72 | } 73 | 74 | 75 | def test_GET_functionality_allows_GET_with_operation_name(): 76 | wsgi = graphql_wsgi(TestSchema) 77 | 78 | c = Client(wsgi) 79 | response = c.get('/', { 80 | 'query': ''' 81 | query helloYou { test(who: "You"), ...shared } 82 | query helloWorld { test(who: "World"), ...shared } 83 | query helloDolly { test(who: "Dolly"), ...shared } 84 | fragment shared on Root { 85 | shared: test(who: "Everyone") 86 | }''', 87 | 'operationName': 'helloWorld' 88 | }) 89 | 90 | assert response.json == { 91 | 'data': { 92 | 'test': 'Hello World', 93 | 'shared': 'Hello Everyone' 94 | } 95 | } 96 | 97 | 98 | def test_POST_functionality_allows_POST_with_JSON_encoding(): 99 | wsgi = graphql_wsgi(TestSchema) 100 | 101 | c = Client(wsgi) 102 | 103 | response = c.post_json('/', {'query': '{test}'}) 104 | 105 | assert response.json == { 106 | 'data': { 107 | 'test': 'Hello World' 108 | } 109 | } 110 | 111 | 112 | def test_POST_functionality_allows_POST_with_url_encoding(): 113 | wsgi = graphql_wsgi(TestSchema) 114 | 115 | c = Client(wsgi) 116 | 117 | response = c.post('/', {'query': b'{test}'}) 118 | 119 | assert response.json == { 120 | 'data': { 121 | 'test': 'Hello World' 122 | } 123 | } 124 | 125 | 126 | def test_POST_functionality_supports_POST_JSON_query_with_string_variables(): 127 | wsgi = graphql_wsgi(TestSchema) 128 | 129 | c = Client(wsgi) 130 | 131 | response = c.post('/', { 132 | 'query': b'query helloWho($who: String){ test(who: $who) }', 133 | 'variables': json.dumps({'who': 'Dolly'}) 134 | }) 135 | 136 | assert response.json == { 137 | 'data': { 138 | 'test': 'Hello Dolly' 139 | } 140 | } 141 | 142 | 143 | def test_POST_functionality_supports_POST_JSON_query_with_JSON_variables(): 144 | wsgi = graphql_wsgi(TestSchema) 145 | 146 | c = Client(wsgi) 147 | 148 | response = c.post_json('/', { 149 | 'query': 'query helloWho($who: String){ test(who: $who) }', 150 | 'variables': json.dumps({'who': 'Dolly'}) 151 | }) 152 | 153 | assert response.json == { 154 | 'data': { 155 | 'test': 'Hello Dolly' 156 | } 157 | } 158 | 159 | 160 | def test_POST_functionality_POST_url_encoded_query_with_string_variables(): 161 | wsgi = graphql_wsgi(TestSchema) 162 | 163 | c = Client(wsgi) 164 | 165 | response = c.post('/', { 166 | 'query': b'query helloWho($who: String){ test(who: $who) }', 167 | 'variables': json.dumps({'who': 'Dolly'}) 168 | }) 169 | 170 | assert response.json == { 171 | 'data': { 172 | 'test': 'Hello Dolly' 173 | } 174 | } 175 | 176 | 177 | def test_POST_functionality_POST_JSON_query_GET_variable_values(): 178 | wsgi = graphql_wsgi(TestSchema) 179 | 180 | c = Client(wsgi) 181 | 182 | response = c.post_json("/?variables=%s" % json.dumps({'who': 'Dolly'}), { 183 | 'query': 'query helloWho($who: String){ test(who: $who) }' 184 | }) 185 | 186 | assert response.json == { 187 | 'data': { 188 | 'test': 'Hello Dolly' 189 | } 190 | } 191 | 192 | 193 | def test_POST_functionality_url_encoded_query_with_GET_variable_values(): 194 | wsgi = graphql_wsgi(TestSchema) 195 | 196 | c = Client(wsgi) 197 | 198 | response = c.post("/?variables=%s" % json.dumps({'who': 'Dolly'}), { 199 | 'query': b'query helloWho($who: String){ test(who: $who) }' 200 | }) 201 | 202 | assert response.json == { 203 | 'data': { 204 | 'test': 'Hello Dolly' 205 | } 206 | } 207 | 208 | 209 | def test_POST_functionaly_POST_raw_text_query_with_GET_variable_values(): 210 | wsgi = graphql_wsgi(TestSchema) 211 | 212 | c = Client(wsgi) 213 | 214 | response = c.post("/?variables=%s" % json.dumps({'who': 'Dolly'}), 215 | b'query helloWho($who: String){ test(who: $who) }', 216 | content_type='application/graphql') 217 | 218 | assert response.json == { 219 | 'data': { 220 | 'test': 'Hello Dolly' 221 | } 222 | } 223 | 224 | 225 | def test_POST_functionality_allows_POST_with_operation_name(): 226 | wsgi = graphql_wsgi(TestSchema) 227 | 228 | c = Client(wsgi) 229 | 230 | response = c.post('/', { 231 | 'query': b''' 232 | query helloYou { test(who: "You"), ...shared } 233 | query helloWorld { test(who: "World"), ...shared } 234 | query helloDolly { test(who: "Dolly"), ...shared } 235 | fragment shared on Root { 236 | shared: test(who: "Everyone") 237 | } 238 | ''', 239 | 'operationName': b'helloWorld' 240 | }) 241 | 242 | assert response.json == { 243 | 'data': { 244 | 'test': 'Hello World', 245 | 'shared': 'Hello Everyone' 246 | } 247 | } 248 | 249 | 250 | def test_POST_functionality_allows_POST_with_GET_operation_name(): 251 | wsgi = graphql_wsgi(TestSchema) 252 | 253 | c = Client(wsgi) 254 | 255 | response = c.post('/?operationName=helloWorld', { 256 | 'query': b''' 257 | query helloYou { test(who: "You"), ...shared } 258 | query helloWorld { test(who: "World"), ...shared } 259 | query helloDolly { test(who: "Dolly"), ...shared } 260 | fragment shared on Root { 261 | shared: test(who: "Everyone") 262 | } 263 | ''' 264 | }) 265 | 266 | assert response.json == { 267 | 'data': { 268 | 'test': 'Hello World', 269 | 'shared': 'Hello Everyone' 270 | } 271 | } 272 | 273 | 274 | def test_POST_functionality_allows_other_UTF_charsets(): 275 | wsgi = graphql_wsgi(TestSchema) 276 | 277 | c = Client(wsgi) 278 | 279 | response = c.post('/', 280 | u'{ test(who: "World") }'.encode('utf_16_le'), 281 | content_type='application/graphql; charset=utf-16') 282 | 283 | assert response.json == { 284 | 'data': { 285 | 'test': 'Hello World' 286 | } 287 | } 288 | 289 | 290 | # Don't test "allows gzipped POST bodies" and "allows deflated POST bodies" 291 | # as this seems to be outside of 292 | # the domain of WSGI and up to the web server itself. 293 | 294 | # "allows for pre-parsed POST bodies" requires some kind of convention 295 | # for pre-parsing that doesn't exist to my knowledge in WSGI or WebOb, 296 | # though with WebOb it can be done with a Request subclass. 297 | 298 | def test_pretty_printing_supports_pretty_printing(): 299 | wsgi = graphql_wsgi(TestSchema, pretty=True) 300 | 301 | c = Client(wsgi) 302 | response = c.get('/', {'query': '{test}'}) 303 | assert response.body == b'''\ 304 | { 305 | "data": { 306 | "test": "Hello World" 307 | } 308 | }''' 309 | 310 | 311 | def test_pretty_printing_configured_by_request(): 312 | def options_from_request(request): 313 | return TestSchema, None, request.GET.get('pretty') == '1' 314 | 315 | wsgi = graphql_wsgi_dynamic(options_from_request) 316 | 317 | c = Client(wsgi) 318 | 319 | response = c.get('/', { 320 | 'query': '{test}', 321 | 'pretty': '0' 322 | }) 323 | 324 | assert response.body == b'{"data":{"test":"Hello World"}}' 325 | 326 | response = c.get('/', { 327 | 'query': '{test}', 328 | 'pretty': '1' 329 | }) 330 | 331 | assert response.body == b'''\ 332 | { 333 | "data": { 334 | "test": "Hello World" 335 | } 336 | }''' 337 | 338 | 339 | def test_error_handling_functionality_handles_field_errors_caught_by_graphql(): 340 | wsgi = graphql_wsgi(TestSchema, pretty=True) 341 | 342 | c = Client(wsgi) 343 | response = c.get('/', {'query': b'{thrower}'}) 344 | 345 | assert response.json == { 346 | 'data': None, 347 | 'errors': [{ 348 | 'message': 'Throws!', 349 | 'locations': [{'line': 1, 'column': 2}] 350 | }] 351 | } 352 | 353 | 354 | def test_error_handling_handles_syntax_errors_caught_by_graphql(): 355 | wsgi = graphql_wsgi(TestSchema, pretty=True) 356 | 357 | c = Client(wsgi) 358 | response = c.get('/', {'query': 'syntaxerror'}, status=400) 359 | 360 | assert response.json == { 361 | 'data': None, 362 | 'errors': [{ 363 | 'message': ('Syntax Error GraphQL request (1:1) ' 364 | 'Unexpected Name "syntaxerror"\n\n1: syntaxerror\n' 365 | ' ^\n'), 366 | 'locations': [{'line': 1, 'column': 1}] 367 | }] 368 | } 369 | 370 | 371 | def test_error_handling_handles_errors_caused_by_a_lack_of_query(): 372 | wsgi = graphql_wsgi(TestSchema, pretty=True) 373 | 374 | c = Client(wsgi) 375 | response = c.get('/', status=400) 376 | 377 | assert response.json == { 378 | 'errors': [{'message': 'Must provide query string.'}] 379 | } 380 | 381 | 382 | def test_error_handling_handles_invalid_JSON_bodies(): 383 | wsgi = graphql_wsgi(TestSchema, pretty=True) 384 | 385 | c = Client(wsgi) 386 | response = c.post('/', 387 | b'{"query":', 388 | content_type='application/json', 389 | status=400) 390 | 391 | assert response.json == { 392 | 'errors': [{'message': 'POST body sent invalid JSON.'}] 393 | } 394 | 395 | 396 | # actually text/plain post is not handled as a real query string 397 | # so this error results. 398 | def test_error_handling_handles_plain_POST_text(): 399 | wsgi = graphql_wsgi(TestSchema, pretty=True) 400 | 401 | c = Client(wsgi) 402 | response = c.post('/?variables=%s' % json.dumps({'who': 'Dolly'}), 403 | b'query helloWho($who: String){ test(who: $who) }', 404 | content_type='text/plain', 405 | status=400) 406 | assert response.json == { 407 | 'errors': [{'message': 'Must provide query string.'}] 408 | } 409 | 410 | 411 | # need to do the test with foobar instead of ascii as in the original 412 | # test as ascii *is* a recognized encoding. 413 | def test_error_handling_handles_unsupported_charset(): 414 | wsgi = graphql_wsgi(TestSchema, pretty=True) 415 | 416 | c = Client(wsgi) 417 | 418 | response = c.post('/', 419 | b'{ test(who: "World") }', 420 | content_type='application/graphql; charset=foobar', 421 | status=415) 422 | assert response.json == { 423 | 'errors': [{'message': 'Unsupported charset "FOOBAR".'}] 424 | } 425 | 426 | 427 | def test_error_handling_handles_unsupported_utf_charset(): 428 | wsgi = graphql_wsgi(TestSchema, pretty=True) 429 | 430 | c = Client(wsgi) 431 | 432 | response = c.post('/', 433 | b'{ test(who: "World") }', 434 | content_type='application/graphql; charset=utf-53', 435 | status=415) 436 | assert response.json == { 437 | 'errors': [{'message': 'Unsupported charset "UTF-53".'}] 438 | } 439 | 440 | 441 | # I have no idea how to handle Content-Encoding with WSGI 442 | @pytest.mark.xfail 443 | def test_error_handling_handles_unknown_encoding(): 444 | wsgi = graphql_wsgi(TestSchema, pretty=True) 445 | 446 | c = Client(wsgi) 447 | 448 | response = c.post('/', 449 | b'!@#$%^*(&^$%#@', 450 | headers={'Content-Encoding': 'garbage'}, 451 | status=415) 452 | assert response.json == { 453 | 'errors': [{'message': 'Unsupported content encoding "garbage".'}] 454 | } 455 | 456 | 457 | def test_error_handling_handles_poorly_formed_variables(): 458 | wsgi = graphql_wsgi(TestSchema, pretty=True) 459 | 460 | c = Client(wsgi) 461 | 462 | response = c.get('/', { 463 | 'variables': b'who:You', 464 | 'query': b'query helloWho($who: String){ test(who: $who) }' 465 | }, status=400) 466 | 467 | assert response.json == { 468 | 'errors': [{'message': 'Variables are invalid JSON.'}] 469 | } 470 | 471 | 472 | def test_error_handling_handles_unsupported_http_methods(): 473 | wsgi = graphql_wsgi(TestSchema, pretty=True) 474 | 475 | c = Client(wsgi) 476 | response = c.put('/?query={test}', status=405) 477 | 478 | assert response.json == { 479 | 'errors': [{'message': 'GraphQL only supports GET and POST requests.'}] 480 | } 481 | 482 | 483 | def test_error_handling_unknown_field(): 484 | wsgi = graphql_wsgi(TestSchema, pretty=True) 485 | 486 | c = Client(wsgi) 487 | # I think this should actually be a 200 status 488 | response = c.get('/?query={unknown}', status=400) 489 | # locations formatting appears to be different here... 490 | assert response.json == { 491 | 'data': None, 492 | 'errors': [ 493 | { 494 | "locations": [ 495 | {'line': 1, 496 | 'column': 2} 497 | ], 498 | "message": u'Cannot query field "unknown" on type "Root".' 499 | } 500 | ] 501 | } 502 | 503 | 504 | # this didn't appear to be covered in the test suite of express-graphql 505 | def test_POST_functionality_variables_in_json_POST_body_not_encoded(): 506 | wsgi = graphql_wsgi(TestSchema) 507 | 508 | c = Client(wsgi) 509 | 510 | response = c.post_json('/', { 511 | 'query': 'query helloWho($who: String){ test(who: $who) }', 512 | 'variables': {'who': 'Dolly'} 513 | }) 514 | 515 | assert response.json == { 516 | 'data': { 517 | 'test': 'Hello Dolly' 518 | } 519 | } 520 | --------------------------------------------------------------------------------