diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..454da04 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,26 @@ +name: 🚀 Deploy to PyPI + +on: + push: + tags: + - 'v*' + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.11" + - name: Build wheel and source tarball + run: | + pip install wheel + python setup.py sdist + - name: Publish a Python distribution to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.pypi_password }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..58601c5 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,22 @@ +name: Lint + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.11" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install tox + - name: Run lint and static type checks + run: tox + env: + TOXENV: pre-commit,mypy diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..ceecb95 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,50 @@ +name: Tests + +on: [push, pull_request] + +jobs: + build: + runs-on: ${{ matrix.os }} + strategy: + max-parallel: 4 + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11"] + os: [ubuntu-latest, windows-latest] + exclude: + - os: windows-latest + python-version: "3.8" + - os: windows-latest + python-version: "3.9" + - os: windows-latest + python-version: "3.11" + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install tox tox-gh-actions + - name: Test with tox + run: tox + + coverage: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.11" + - name: Install test dependencies + run: | + python -m pip install --upgrade pip + pip install .[test] + - name: Test with coverage + run: pytest --cov=graphql_server --cov-report=xml tests + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v1 diff --git a/.gitignore b/.gitignore index 608847c..bfac963 100644 --- a/.gitignore +++ b/.gitignore @@ -1,19 +1,207 @@ -*.pyc -*.pyo +# Created by https://www.gitignore.io/api/python,intellij+all,visualstudiocode +# Edit at https://www.gitignore.io/?templates=python,intellij+all,visualstudiocode + +### Intellij+all ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Intellij+all Patch ### +# Ignores the whole .idea folder and all .iml files +# See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 + +.idea/ + +# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 + +*.iml +modules.xml +.idea/misc.xml +*.ipr + +# Sonarlint plugin +.idea/sonarlint + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg *.egg -*.egg-info +MANIFEST -.cache +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.venv/ .coverage -.idea -.mypy_cache -.pytest_cache -.tox -.venv +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# pyenv +.python-version + +# Pycharm venv +venv/ + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +### VisualStudioCode ### .vscode -/build/ -/dist/ +### VisualStudioCode Patch ### +# Ignore all local history of files +.history -docs +# End of https://www.gitignore.io/api/python,intellij+all,visualstudiocode diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..bb933c2 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,27 @@ +default_language_version: + python: python3.11 +exclude: LICENSE +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: check-merge-conflict + - id: check-json + - id: check-yaml + - id: debug-statements + - id: end-of-file-fixer + exclude: ^docs/.*$ + - id: pretty-format-json + args: + - --autofix + - id: trailing-whitespace +- repo: https://github.com/mgedmin/check-manifest + rev: "0.49" + hooks: + - id: check-manifest +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.1.2 + hooks: + - id: ruff + args: [--fix, --exit-non-zero-on-fix, --show-fixes] + - id: ruff-format diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 0000000..228088e --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,34 @@ +select = [ + "E", # pycodestyle + "W", # pycodestyle + "F", # pyflake + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade +] + +ignore = [ + "E501", # line-too-long + "B904", # check for raise statements in exception handlers that lack a from clause +] + +exclude = [ + "**/docs", +] + +target-version = "py38" + +[per-file-ignores] +# Ignore unused imports (F401) in these files +"__init__.py" = ["F401"] + +[isort] +known-first-party = ["graphql_server"] +combine-as-imports = true + +[pyupgrade] +# this keeps annotation syntaxes like Union[X, Y] instead of X | Y +# to not break Python 3.8 +# https://beta.ruff.rs/docs/settings/#pyupgrade-keep-runtime-typing +keep-runtime-typing = true diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 7789878..0000000 --- a/.travis.yml +++ /dev/null @@ -1,29 +0,0 @@ -language: python -sudo: false -python: - - 2.7 - - 3.5 - - 3.6 - - 3.7 - - 3.8 - - 3.9-dev - - pypy - - pypy3 -matrix: - include: - - python: 3.6 - env: TOXENV=flake8,black,import-order,mypy,manifest -cache: pip -install: pip install tox-travis codecov -script: tox -after_success: codecov -deploy: - provider: pypi - on: - branch: master - tags: true - python: 3.7 - skip_existing: true - user: __token__ - password: - secure: WcZf7AVMDzheXWUxNhZF/TUcyvyCdHZGyhHTakjBhUs8I8khSvlMPofaXTdN1Qn3WbHPK+IXeIPh/2NX0Le3Cdzp08Q/Tgrf9EZ4y02UrZxwSxtsUmjCVd8GaCsQnhR5t5cgrtw33OAf0O22rUnMXsFtw7xMIuCNTgFiYclNbHzYbvnJAEcY3qE8RBbP8zF5Brx+Bl49SjfVR3dJ7CBkjgC9scZjSBAo/yc64d506W59LOjfvXEiDtGUH2gxZNwNiteZtI3frMYqLRjS563SwEFlG36B8g0hBOj6FVpU+YXeImYXw3XFqC6dCvcwn1dAf/vUZ4IDiDIVf5KvFcyDx0ZwZlMSzqlkLVpSDGqPU+7Mx15NW00Yk2+Zs2ZWFMK+g5WtSehhrAWR6El3d0MRlDXKgt9QbCRyh8b2jPV/vQZN2FOBOg9V9a6IszOy/W1J81q39cLOroBhQF4mDFYTAQ5QpBVUyauAfB49QzXsmSWy2uOTsbgo+oAc+OGJ6q9vXCzNqHxhUvtDT9HIq4w5ixw9wqtpSf6n+l2F2RFl5SzHIR7Dt0m9Eg2Ig5NqSGlymz46ZcxpRjd4wVXALD4M8usqy35jGTeEXsqSTO98n3jwKTj/7Xi6GOZuBlwW+SGAjXQ0vzlWD3AEv0Jnh+4AH5UqWwBeD1skw8gtbjM4dos= diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c573f21..79d237b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing -Thanks for helping to make graphql-server-core awesome! +Thanks for helping to make graphql-server awesome! We welcome all kinds of contributions: @@ -12,7 +12,7 @@ We welcome all kinds of contributions: ## Getting started -If you have a specific contribution in mind, be sure to check the [issues](https://github.com/graphql-python/graphql-server-core/issues) and [pull requests](https://github.com/graphql-python/graphql-server-core/pulls) in progress - someone could already be working on something similar and you can help out. +If you have a specific contribution in mind, be sure to check the [issues](https://github.com/graphql-python/graphql-server/issues) and [pull requests](https://github.com/graphql-python/graphql-server/pulls) in progress - someone could already be working on something similar and you can help out. ## Project setup @@ -22,7 +22,7 @@ If you have a specific contribution in mind, be sure to check the [issues](https After cloning this repo, create a virtualenv: ```console -virtualenv graphql-server-core-dev +virtualenv graphql-server-dev ``` Activate the virtualenv and install dependencies by running: @@ -31,8 +31,7 @@ Activate the virtualenv and install dependencies by running: python pip install -e ".[test]" ``` -If you are using Linux or MacOS, you can make use of Makefile command -`make dev-setup`, which is a shortcut for the above python command. +If you are using Linux or MacOS, you can make use of Makefile command `make dev-setup`, which is a shortcut for the above python command. ### Development on Conda @@ -57,11 +56,10 @@ And you ready to start development! After developing, the full test suite can be evaluated by running: ```sh -pytest tests --cov=graphql-server-core -vv +pytest tests --cov=graphql-server -vv ``` -If you are using Linux or MacOS, you can make use of Makefile command -`make tests`, which is a shortcut for the above python command. +If you are using Linux or MacOS, you can make use of Makefile command `make tests`, which is a shortcut for the above python command. You can also test on several python environments by using tox. @@ -73,8 +71,7 @@ Install tox: pip install tox ``` -Run `tox` on your virtualenv (do not forget to activate it!) -and that's it! +Run `tox` on your virtualenv (do not forget to activate it!) and that's it! ### Running tox on Conda @@ -89,5 +86,4 @@ This install tox underneath so no need to install it before. Then uncomment the `requires = tox-conda` line on `tox.ini` file. -Run `tox` and you will see all the environments being created -and all passing tests. :rocket: +Run `tox` and you will see all the environments being created and all passing tests. :rocket: diff --git a/MANIFEST.in b/MANIFEST.in index 12b4ad7..67b0e2f 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -6,6 +6,10 @@ include CONTRIBUTING.md include codecov.yml include tox.ini +include .pre-commit-config.yaml +include .ruff.toml + +recursive-include docs *.md *.svg graft tests prune bin diff --git a/README.md b/README.md index fdb3d40..3e4588d 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,45 @@ -# GraphQL-Server-Core + -[![PyPI version](https://badge.fury.io/py/graphql-server-core.svg)](https://badge.fury.io/py/graphql-server-core) -[![Build Status](https://travis-ci.org/graphql-python/graphql-server-core.svg?branch=master)](https://travis-ci.org/graphql-python/graphql-server-core) -[![Coverage Status](https://codecov.io/gh/graphql-python/graphql-server-core/branch/master/graph/badge.svg)](https://codecov.io/gh/graphql-python/graphql-server-core) +[![PyPI version](https://badge.fury.io/py/graphql-server.svg)](https://badge.fury.io/py/graphql-server) +[![Coverage Status](https://codecov.io/gh/graphql-python/graphql-server/branch/master/graph/badge.svg)](https://codecov.io/gh/graphql-python/graphql-server) -GraphQL-Server-Core is a base library that serves as a helper +GraphQL-Server is a base library that serves as a helper for building GraphQL servers or integrations into existing web frameworks using [GraphQL-Core](https://github.com/graphql-python/graphql-core). -## Existing integrations built with GraphQL-Server-Core +## Integrations built with GraphQL-Server -| Server integration | Package | -|---|---| -| Flask | [flask-graphql](https://github.com/graphql-python/flask-graphql/) | -| Sanic |[sanic-graphql](https://github.com/graphql-python/sanic-graphql/) | -| AIOHTTP | [aiohttp-graphql](https://github.com/graphql-python/aiohttp-graphql) | -| WebOb (Pyramid, TurboGears) | [webob-graphql](https://github.com/graphql-python/webob-graphql/) | -| WSGI | [wsgi-graphql](https://github.com/moritzmhmk/wsgi-graphql) | -| Responder | [responder.ext.graphql](https://github.com/kennethreitz/responder/blob/master/responder/ext/graphql.py) | +| Server integration | Docs | +| --------------------------- | --------------------------------------------------------------------------------------- | +| Flask | [flask](https://github.com/graphql-python/graphql-server/blob/master/docs/flask.md) | +| Sanic | [sanic](https://github.com/graphql-python/graphql-server/blob/master/docs/sanic.md) | +| AIOHTTP | [aiohttp](https://github.com/graphql-python/graphql-server/blob/master/docs/aiohttp.md) | +| WebOb (Pyramid, TurboGears) | [webob](https://github.com/graphql-python/graphql-server/blob/master/docs/webob.md) | + +## Other integrations built with GraphQL-Server + +| Server integration | Package | +| ------------------ | ------------------------------------------------------------------------------------------------------- | +| WSGI | [wsgi-graphql](https://github.com/moritzmhmk/wsgi-graphql) | +| Responder | [responder.ext.graphql](https://github.com/kennethreitz/responder/blob/master/responder/ext/graphql.py) | ## Other integrations using GraphQL-Core or Graphene -| Server integration | Package | -|---|---| -| Django | [graphene-django](https://github.com/graphql-python/graphene-django/) | +| Server integration | Package | +| ------------------ | --------------------------------------------------------------------- | +| Django | [graphene-django](https://github.com/graphql-python/graphene-django/) | ## Documentation The `graphql_server` package provides these public helper functions: - * `run_http_query` - * `encode_execution_results` - * `load_json_body` - * `json_encode` - * `json_encode_pretty` +- `run_http_query` +- `encode_execution_results` +- `load_json_body` +- `json_encode` +- `json_encode_pretty` + +**NOTE:** the `json_encode_pretty` is kept as backward compatibility change as it uses `json_encode` with `pretty` parameter set to `True`. All functions in the package are annotated with type hints and docstrings, and you can build HTML documentation from these using `bin/build_docs`. @@ -44,4 +50,5 @@ blueprint to build your own integration or GraphQL server implementations. Please let us know when you have built something new, so we can list it here. ## Contributing -See [CONTRIBUTING.md](CONTRIBUTING.md) + +See [CONTRIBUTING.md](https://github.com/graphql-python/graphql-server/blob/master/CONTRIBUTING.md) diff --git a/codecov.yml b/codecov.yml index c393a12..c155caa 100644 --- a/codecov.yml +++ b/codecov.yml @@ -7,4 +7,4 @@ coverage: status: project: default: - target: auto \ No newline at end of file + target: auto diff --git a/docs/_static/graphql-server-logo.svg b/docs/_static/graphql-server-logo.svg new file mode 100644 index 0000000..7cf6592 --- /dev/null +++ b/docs/_static/graphql-server-logo.svg @@ -0,0 +1 @@ +graphql-server-logo \ No newline at end of file diff --git a/docs/aiohttp.md b/docs/aiohttp.md new file mode 100644 index 0000000..b301855 --- /dev/null +++ b/docs/aiohttp.md @@ -0,0 +1,74 @@ +# aiohttp-Graphql + +Adds GraphQL support to your aiohttp application. + +## Installation + +To install the integration with aiohttp, run the below command on your terminal. + +`pip install graphql-server[aiohttp]` + +## Usage + +Use the `GraphQLView` view from `graphql_server.aiohttp` + +```python +from aiohttp import web +from graphql_server.aiohttp import GraphQLView + +from schema import schema + +app = web.Application() + +GraphQLView.attach(app, schema=schema, graphiql=True) + +# Optional, for adding batch query support (used in Apollo-Client) +GraphQLView.attach(app, schema=schema, batch=True, route_path="/graphql/batch") + +if __name__ == '__main__': + web.run_app(app) +``` + +This will add `/graphql` endpoint to your app (customizable by passing `route_path='/mypath'` to `GraphQLView.attach`) and enable the GraphiQL IDE. + +Note: `GraphQLView.attach` is just a convenience function, and the same functionality can be achieved with + +```python +gql_view = GraphQLView(schema=schema, graphiql=True) +app.router.add_route('*', '/graphql', gql_view, name='graphql') +``` + +It's worth noting that the the "view function" of `GraphQLView` is contained in `GraphQLView.__call__`. So, when you create an instance, that instance is callable with the request object as the sole positional argument. To illustrate: + +```python +gql_view = GraphQLView(schema=Schema, **kwargs) +gql_view(request) # <-- the instance is callable and expects a `aiohttp.web.Request` object. +``` + +### Supported options for GraphQLView + + * `schema`: The GraphQL schema object that you want the view to execute when it gets a valid request. Accepts either an object of type `GraphQLSchema` from `graphql-core` or `Schema` from `graphene`. For Graphene v3, passing either `schema: graphene.Schema` or `schema.graphql_schema` is allowed. + * `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`. + * `root_value`: The `root_value` you want to provide to graphql `execute`. + * `pretty`: Whether or not you want the response to be pretty printed JSON. + * `graphiql`: If `True`, may present [GraphiQL](https://github.com/graphql/graphiql) when loaded directly from a browser (a useful tool for debugging and exploration). + * `graphiql_version`: The graphiql version to load. Defaults to **"2.2.0"**. + * `graphiql_template`: Inject a Jinja template string to customize GraphiQL. + * `graphiql_html_title`: The graphiql title to display. Defaults to **"GraphiQL"**. + * `jinja_env`: Sets jinja environment to be used to process GraphiQL template. If Jinja’s async mode is enabled (by `enable_async=True`), uses `Template.render_async` instead of `Template.render`. If environment is not set, fallbacks to simple regex-based renderer. + * `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)) + * `middleware`: A list of graphql [middlewares](http://docs.graphene-python.org/en/latest/execution/middleware/). + * `validation_rules`: A list of graphql validation rules. + * `execution_context_class`: Specifies a custom execution context class. + * `max_age`: Sets the response header Access-Control-Max-Age for preflight requests. + * `encode`: the encoder to use for responses (sensibly defaults to `graphql_server.json_encode`). + * `format_error`: the error formatter to use for responses (sensibly defaults to `graphql_server.default_format_error`. + * `enable_async`: whether `async` mode will be enabled. + * `subscriptions`: The GraphiQL socket endpoint for using subscriptions in graphql-ws. + * `headers`: An optional GraphQL string to use as the initial displayed request headers, if not provided, the stored headers will be used. + * `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. +* `header_editor_enabled`: An optional boolean which enables the header editor when true. Defaults to **false**. +* `should_persist_headers`: An optional boolean which enables to persist headers to storage when true. Defaults to **false**. + +## Contributing +See [CONTRIBUTING.md](../CONTRIBUTING.md) diff --git a/docs/flask.md b/docs/flask.md new file mode 100644 index 0000000..dfe0aa7 --- /dev/null +++ b/docs/flask.md @@ -0,0 +1,77 @@ +# Flask-GraphQL + +Adds GraphQL support to your Flask application. + +## Installation + +To install the integration with Flask, run the below command on your terminal. + +`pip install graphql-server[flask]` + +## Usage + +Use the `GraphQLView` view from `graphql_server.flask`. + +```python +from flask import Flask +from graphql_server.flask import GraphQLView + +from schema import schema + +app = Flask(__name__) + +app.add_url_rule('/graphql', view_func=GraphQLView.as_view( + 'graphql', + schema=schema, + graphiql=True, +)) + +# Optional, for adding batch query support (used in Apollo-Client) +app.add_url_rule('/graphql/batch', view_func=GraphQLView.as_view( + 'graphql', + schema=schema, + batch=True +)) + +if __name__ == '__main__': + app.run() +``` + +This will add `/graphql` endpoint to your app and enable the GraphiQL IDE. + +### Supported options for GraphQLView + + * `schema`: The GraphQL schema object that you want the view to execute when it gets a valid request. Accepts either an object of type `GraphQLSchema` from `graphql-core` or `Schema` from `graphene`. For Graphene v3, passing either `schema: graphene.Schema` or `schema.graphql_schema` is allowed. + * `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`. + * `root_value`: The `root_value` you want to provide to graphql `execute`. + * `pretty`: Whether or not you want the response to be pretty printed JSON. + * `graphiql`: If `True`, may present [GraphiQL](https://github.com/graphql/graphiql) when loaded directly from a browser (a useful tool for debugging and exploration). + * `graphiql_version`: The graphiql version to load. Defaults to **"2.2.0"**. + * `graphiql_template`: Inject a Jinja template string to customize GraphiQL. + * `graphiql_html_title`: The graphiql title to display. Defaults to **"GraphiQL"**. + * `jinja_env`: Sets jinja environment to be used to process GraphiQL template. If environment is not set, fallbacks to simple regex-based renderer. + * `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)) + * `middleware`: A list of graphql [middlewares](http://docs.graphene-python.org/en/latest/execution/middleware/). + * `validation_rules`: A list of graphql validation rules. + * `execution_context_class`: Specifies a custom execution context class. + * `encode`: the encoder to use for responses (sensibly defaults to `graphql_server.json_encode`). + * `format_error`: the error formatter to use for responses (sensibly defaults to `graphql_server.default_format_error`. + * `subscriptions`: The GraphiQL socket endpoint for using subscriptions in graphql-ws. + * `headers`: An optional GraphQL string to use as the initial displayed request headers, if not provided, the stored headers will be used. + * `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. +* `header_editor_enabled`: An optional boolean which enables the header editor when true. Defaults to **false**. +* `should_persist_headers`: An optional boolean which enables to persist headers to storage when true. Defaults to **false**. + + +You can also subclass `GraphQLView` and overwrite `get_root_value(self, request)` to have a dynamic root value +per request. + +```python +class UserRootValue(GraphQLView): + def get_root_value(self, request): + return request.user + +``` + +## Contributing +See [CONTRIBUTING.md](../CONTRIBUTING.md) diff --git a/docs/sanic.md b/docs/sanic.md new file mode 100644 index 0000000..102e38d --- /dev/null +++ b/docs/sanic.md @@ -0,0 +1,75 @@ +# Sanic-GraphQL + +Adds GraphQL support to your Sanic application. + +## Installation + +To install the integration with Sanic, run the below command on your terminal. + +`pip install graphql-server[sanic]` + +## Usage + +Use the `GraphQLView` view from `graphql_server.sanic` + +```python +from graphql_server.sanic import GraphQLView +from sanic import Sanic + +from schema import schema + +app = Sanic(name="Sanic Graphql App") + +app.add_route( + GraphQLView.as_view(schema=schema, graphiql=True), + '/graphql' +) + +# Optional, for adding batch query support (used in Apollo-Client) +app.add_route( + GraphQLView.as_view(schema=schema, batch=True), + '/graphql/batch' +) + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=8000) +``` + +This will add `/graphql` endpoint to your app and enable the GraphiQL IDE. + +### Supported options for GraphQLView + + * `schema`: The GraphQL schema object that you want the view to execute when it gets a valid request. Accepts either an object of type `GraphQLSchema` from `graphql-core` or `Schema` from `graphene`. For Graphene v3, passing either `schema: graphene.Schema` or `schema.graphql_schema` is allowed. + * `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`. + * `root_value`: The `root_value` you want to provide to graphql `execute`. + * `pretty`: Whether or not you want the response to be pretty printed JSON. + * `graphiql`: If `True`, may present [GraphiQL](https://github.com/graphql/graphiql) when loaded directly from a browser (a useful tool for debugging and exploration). + * `graphiql_version`: The graphiql version to load. Defaults to **"2.2.0"**. + * `graphiql_template`: Inject a Jinja template string to customize GraphiQL. + * `graphiql_html_title`: The graphiql title to display. Defaults to **"GraphiQL"**. + * `jinja_env`: Sets jinja environment to be used to process GraphiQL template. If Jinja’s async mode is enabled (by `enable_async=True`), uses `Template.render_async` instead of `Template.render`. If environment is not set, fallbacks to simple regex-based renderer. + * `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)) + * `middleware`: A list of graphql [middlewares](http://docs.graphene-python.org/en/latest/execution/middleware/). + * `validation_rules`: A list of graphql validation rules. + * `execution_context_class`: Specifies a custom execution context class. + * `max_age`: Sets the response header Access-Control-Max-Age for preflight requests. + * `encode`: the encoder to use for responses (sensibly defaults to `graphql_server.json_encode`). + * `format_error`: the error formatter to use for responses (sensibly defaults to `graphql_server.default_format_error`. + * `enable_async`: whether `async` mode will be enabled. + * `subscriptions`: The GraphiQL socket endpoint for using subscriptions in graphql-ws. + * `headers`: An optional GraphQL string to use as the initial displayed request headers, if not provided, the stored headers will be used. + * `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. +* `header_editor_enabled`: An optional boolean which enables the header editor when true. Defaults to **false**. +* `should_persist_headers`: An optional boolean which enables to persist headers to storage when true. Defaults to **false**. + + +You can also subclass `GraphQLView` and overwrite `get_root_value(self, request)` to have a dynamic root value per request. + +```python +class UserRootValue(GraphQLView): + def get_root_value(self, request): + return request.user +``` + +## Contributing +See [CONTRIBUTING.md](../CONTRIBUTING.md) diff --git a/docs/webob.md b/docs/webob.md new file mode 100644 index 0000000..2f88a31 --- /dev/null +++ b/docs/webob.md @@ -0,0 +1,64 @@ +# WebOb-GraphQL + +Adds GraphQL support to your WebOb (Pyramid, Pylons, ...) application. + +## Installation + +To install the integration with WebOb, run the below command on your terminal. + +`pip install graphql-server[webob]` + +## Usage + +Use the `GraphQLView` view from `graphql_server.webob` + +### Pyramid + +```python +from wsgiref.simple_server import make_server +from pyramid.config import Configurator + +from graphql_server.webob import GraphQLView + +from schema import schema + +def graphql_view(request): + return GraphQLView(request=request, schema=schema, graphiql=True).dispatch_request(request) + +if __name__ == '__main__': + with Configurator() as config: + config.add_route('graphql', '/graphql') + config.add_view(graphql_view, route_name='graphql') + app = config.make_wsgi_app() + server = make_server('0.0.0.0', 6543, app) + server.serve_forever() +``` + +This will add `/graphql` endpoint to your app and enable the GraphiQL IDE. + +### Supported options for GraphQLView + + * `schema`: The GraphQL schema object that you want the view to execute when it gets a valid request. Accepts either an object of type `GraphQLSchema` from `graphql-core` or `Schema` from `graphene`. For Graphene v3, passing either `schema: graphene.Schema` or `schema.graphql_schema` is allowed. + * `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`. + * `root_value`: The `root_value` you want to provide to graphql `execute`. + * `pretty`: Whether or not you want the response to be pretty printed JSON. + * `graphiql`: If `True`, may present [GraphiQL](https://github.com/graphql/graphiql) when loaded directly from a browser (a useful tool for debugging and exploration). + * `graphiql_version`: The graphiql version to load. Defaults to **"2.2.0"**. + * `graphiql_template`: Inject a Jinja template string to customize GraphiQL. + * `graphiql_html_title`: The graphiql title to display. Defaults to **"GraphiQL"**. + * `jinja_env`: Sets jinja environment to be used to process GraphiQL template. If environment is not set, fallbacks to simple regex-based renderer. + * `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)) + * `middleware`: A list of graphql [middlewares](http://docs.graphene-python.org/en/latest/execution/middleware/). + * `validation_rules`: A list of graphql validation rules. + * `execution_context_class`: Specifies a custom execution context class. + * `encode`: the encoder to use for responses (sensibly defaults to `graphql_server.json_encode`). + * `format_error`: the error formatter to use for responses (sensibly defaults to `graphql_server.default_format_error`. + * `enable_async`: whether `async` mode will be enabled. + * `subscriptions`: The GraphiQL socket endpoint for using subscriptions in graphql-ws. + * `headers`: An optional GraphQL string to use as the initial displayed request headers, if not provided, the stored headers will be used. + * `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. +* `header_editor_enabled`: An optional boolean which enables the header editor when true. Defaults to **false**. +* `should_persist_headers`: An optional boolean which enables to persist headers to storage when true. Defaults to **false**. + +## Contributing +See [CONTRIBUTING.md](../CONTRIBUTING.md) diff --git a/graphql_server/__init__.py b/graphql_server/__init__.py index cb802ee..f8456de 100644 --- a/graphql_server/__init__.py +++ b/graphql_server/__init__.py @@ -1,76 +1,74 @@ """ -GraphQL-Server-Core +GraphQL-Server =================== -GraphQL-Server-Core is a base library that serves as a helper +GraphQL-Server is a base library that serves as a helper for building GraphQL servers or integrations into existing web frameworks using [GraphQL-Core](https://github.com/graphql-python/graphql-core). """ - - import json from collections import namedtuple +from collections.abc import MutableMapping +from typing import Any, Callable, Collection, Dict, List, Optional, Type, Union, cast -import six - -from promise import promisify, is_thenable, Promise - -from graphql import get_default_backend -from graphql.error import format_error as default_format_error -from graphql.execution import ExecutionResult -from graphql.execution.executors.sync import SyncExecutor -from graphql.type import GraphQLSchema +from graphql.error import GraphQLError +from graphql.execution import ExecutionResult, execute +from graphql.language import OperationType, parse +from graphql.pyutils import AwaitableOrValue +from graphql.type import GraphQLSchema, validate_schema +from graphql.utilities import get_operation_ast +from graphql.validation import ASTValidationRule, validate from .error import HttpQueryError +from .version import version, version_info -try: # pragma: no cover (Python >= 3.3) - from collections.abc import MutableMapping -except ImportError: # pragma: no cover (Python < 3.3) - # noinspection PyUnresolvedReferences,PyProtectedMember - from collections import MutableMapping - -# Necessary for static type checking -# noinspection PyUnreachableCode -if False: # pragma: no cover - # flake8: noqa - from typing import Any, Callable, Dict, List, Optional, Type, Union - from graphql import GraphQLBackend +# The GraphQL-Server 3 version info. +__version__ = version +__version_info__ = version_info __all__ = [ + "version", + "version_info", "run_http_query", "encode_execution_results", "load_json_body", "json_encode", "json_encode_pretty", "HttpQueryError", - "RequestParams", - "ServerResults", + "GraphQLParams", + "GraphQLResponse", "ServerResponse", + "format_execution_result", + "format_error_default", ] # The public data structures -RequestParams = namedtuple("RequestParams", "query variables operation_name") - -ServerResults = namedtuple("ServerResults", "results params") - +GraphQLParams = namedtuple("GraphQLParams", "query variables operation_name") +GraphQLResponse = namedtuple("GraphQLResponse", "results params") ServerResponse = namedtuple("ServerResponse", "body status_code") # The public helper functions +def format_error_default(error: GraphQLError) -> Dict: + """The default function for converting GraphQLError to a dictionary.""" + return cast(Dict, error.formatted) + + def run_http_query( - schema, # type: GraphQLSchema - request_method, # type: str - data, # type: Union[Dict, List[Dict]] - query_data=None, # type: Optional[Dict] - batch_enabled=False, # type: bool - catch=False, # type: bool - **execute_options # type: Any -): + schema: GraphQLSchema, + request_method: str, + data: Union[Dict, List[Dict]], + query_data: Optional[Dict] = None, + batch_enabled: bool = False, + catch: bool = False, + run_sync: bool = True, + **execute_options, +) -> GraphQLResponse: """Execute GraphQL coming from an HTTP query against a given schema. You need to pass the schema (that is supposed to be already validated), @@ -87,7 +85,7 @@ def run_http_query( and the list of parameters that have been used for execution as second item. """ if not isinstance(schema, GraphQLSchema): - raise TypeError("Expected a GraphQL schema, but received {!r}.".format(schema)) + raise TypeError(f"Expected a GraphQL schema, but received {schema!r}.") if request_method not in ("get", "post"): raise HttpQueryError( 405, @@ -95,9 +93,7 @@ def run_http_query( headers={"Allow": "GET, POST"}, ) if catch: - catch_exc = ( - HttpQueryError - ) # type: Union[Type[HttpQueryError], Type[_NoException]] + catch_exc: Union[Type[HttpQueryError], Type[_NoException]] = HttpQueryError else: catch_exc = _NoException is_batch = isinstance(data, list) @@ -108,7 +104,7 @@ def run_http_query( if not is_batch: if not isinstance(data, (dict, MutableMapping)): raise HttpQueryError( - 400, "GraphQL params should be a dict. Received {!r}.".format(data) + 400, f"GraphQL params should be a dict. Received {data!r}." ) data = [data] elif not batch_enabled: @@ -117,50 +113,45 @@ def run_http_query( if not data: raise HttpQueryError(400, "Received an empty list in the batch request.") - extra_data = {} # type: Dict[str, Any] + extra_data: Dict[str, Any] = {} # If is a batch request, we don't consume the data from the query if not is_batch: extra_data = query_data or {} - all_params = [get_graphql_params(entry, extra_data) for entry in data] + all_params: List[GraphQLParams] = [ + get_graphql_params(entry, extra_data) for entry in data + ] + + results: List[Optional[AwaitableOrValue[ExecutionResult]]] = [ + get_response( + schema, params, catch_exc, allow_only_query, run_sync, **execute_options + ) + for params in all_params + ] + return GraphQLResponse(results, all_params) - if execute_options.get("return_promise"): - results = [ - get_response(schema, params, catch_exc, allow_only_query, **execute_options) - for params in all_params - ] - else: - executor = execute_options.get("executor") - response_executor = executor if executor else SyncExecutor() - - response_promises = [ - response_executor.execute( - get_response, - schema, - params, - catch_exc, - allow_only_query, - **execute_options - ) - for params in all_params - ] - response_executor.wait_until_finished() - results = [ - result.get() if is_thenable(result) else result - for result in response_promises - ] +def json_encode(data: Union[Dict, List], pretty: bool = False) -> str: + """Serialize the given data(a dictionary or a list) using JSON. + + The given data (a dictionary or a list) will be serialized using JSON + and returned as a string that will be nicely formatted if you set pretty=True. + """ + if not pretty: + return json.dumps(data, separators=(",", ":")) + return json.dumps(data, indent=2, separators=(",", ": ")) - return ServerResults(results, all_params) + +def json_encode_pretty(data: Union[Dict, List]) -> str: + return json_encode(data, True) def encode_execution_results( - execution_results, # type: List[Optional[ExecutionResult]] - format_error=None, # type: Callable[[Exception], Dict] - is_batch=False, # type: bool - encode=None, # type: Callable[[Dict], Any] -): - # type: (...) -> ServerResponse + execution_results: List[Optional[ExecutionResult]], + format_error: Callable[[GraphQLError], Dict] = format_error_default, + is_batch: bool = False, + encode: Callable[[Dict], Any] = json_encode, +) -> ServerResponse: """Serialize the ExecutionResults. This function takes the ExecutionResults that are returned by run_http_query() @@ -174,7 +165,7 @@ def encode_execution_results( a status code of 200 or 400 in case any result was invalid as the second item. """ results = [ - format_execution_result(execution_result, format_error or default_format_error) + format_execution_result(execution_result, format_error) for execution_result in execution_results ] result, status_codes = zip(*results) @@ -183,7 +174,7 @@ def encode_execution_results( if not is_batch: result = result[0] - return ServerResponse((encode or json_encode)(result), status_code) + return ServerResponse(encode(result), status_code) def load_json_body(data): @@ -199,24 +190,6 @@ def load_json_body(data): raise HttpQueryError(400, "POST body sent invalid JSON.") -def json_encode(data, pretty=False): - # type: (Union[Dict,List],Optional[bool]) -> str - """Serialize the given data(a dictionary or a list) using JSON. - - The given data (a dictionary or a list) will be serialized using JSON - and returned as a string that will be nicely formatted if you set pretty=True. - """ - if pretty: - return json_encode_pretty(data) - return json.dumps(data, separators=(",", ":")) - - -def json_encode_pretty(data): - # type: (Union[Dict,List]) -> str - """Serialize the given data using JSON with nice formatting.""" - return json.dumps(data, indent=2, separators=(",", ": ")) - - # Some more private helpers FormattedResult = namedtuple("FormattedResult", "result status_code") @@ -226,8 +199,7 @@ class _NoException(Exception): """Private exception used when we don't want to catch any real exception.""" -def get_graphql_params(data, query_data): - # type: (Dict, Dict) -> RequestParams +def get_graphql_params(data: Dict, query_data: Dict) -> GraphQLParams: """Fetch GraphQL query, variables and operation name parameters from given data. You need to pass both the data from the HTTP request body and the HTTP query string. @@ -240,18 +212,17 @@ def get_graphql_params(data, query_data): # document_id = data.get('documentId') operation_name = data.get("operationName") or query_data.get("operationName") - return RequestParams(query, load_json_variables(variables), operation_name) + return GraphQLParams(query, load_json_variables(variables), operation_name) -def load_json_variables(variables): - # type: (Optional[Union[str, Dict]]) -> Optional[Dict] +def load_json_variables(variables: Optional[Union[str, Dict]]) -> Optional[Dict]: """Return the given GraphQL variables as a dictionary. The function returns the given GraphQL variables, making sure they are deserialized from JSON to a dictionary first if necessary. In case of invalid JSON input, an HttpQueryError will be raised. """ - if variables and isinstance(variables, six.string_types): + if variables and isinstance(variables, str): try: return json.loads(variables) except Exception: @@ -259,82 +230,78 @@ def load_json_variables(variables): return variables # type: ignore -def execute_graphql_request( - schema, # type: GraphQLSchema - params, # type: RequestParams - allow_only_query=False, # type: bool - backend=None, # type: GraphQLBackend - **kwargs # type: Any -): - # type: (...) -> ExecutionResult - """Execute a GraphQL request and return an ExecutionResult. - - You need to pass the GraphQL schema and the GraphQLParams that you can get - with the get_graphql_params() function. If you only want to allow GraphQL query - operations, then set allow_only_query=True. You can also specify a custom - GraphQLBackend instance that shall be used by GraphQL-Core instead of the - default one. All other keyword arguments are passed on to the GraphQL-Core - function for executing GraphQL queries. - """ - if not params.query: - raise HttpQueryError(400, "Must provide query string.") - - try: - if not backend: - backend = get_default_backend() - document = backend.document_from_string(schema, params.query) - except Exception as e: - return ExecutionResult(errors=[e], invalid=True) - - if allow_only_query: - operation_type = document.get_operation_type(params.operation_name) - if operation_type and operation_type != "query": - raise HttpQueryError( - 405, - "Can only perform a {} operation from a POST request.".format( - operation_type - ), - headers={"Allow": "POST"}, - ) - - try: - return document.execute( - operation_name=params.operation_name, - variable_values=params.variables, - **kwargs - ) - except Exception as e: - return ExecutionResult(errors=[e], invalid=True) - - -@promisify -def execute_graphql_request_as_promise(*args, **kwargs): - return execute_graphql_request(*args, **kwargs) +def assume_not_awaitable(_value: Any) -> bool: + """Replacement for isawaitable if everything is assumed to be synchronous.""" + return False def get_response( - schema, # type: GraphQLSchema - params, # type: RequestParams - catch_exc, # type: Type[BaseException] - allow_only_query=False, # type: bool - **kwargs # type: Any -): - # type: (...) -> Optional[Union[ExecutionResult, Promise[ExecutionResult]]] + schema: GraphQLSchema, + params: GraphQLParams, + catch_exc: Type[BaseException], + allow_only_query: bool = False, + run_sync: bool = True, + validation_rules: Optional[Collection[Type[ASTValidationRule]]] = None, + max_errors: Optional[int] = None, + **kwargs, +) -> Optional[AwaitableOrValue[ExecutionResult]]: """Get an individual execution result as response, with option to catch errors. - This does the same as execute_graphql_request() except that you can catch errors - that belong to an exception class that you need to pass as a parameter. + This will validate the schema (if the schema is used for the first time), + parse the query, check if this is a query if allow_only_query is set to True, + validate the query (optionally with additional validation rules and limiting + the number of errors), execute the request (asynchronously if run_sync is not + set to True), and return the ExecutionResult. You can also catch all errors that + belong to an exception class specified by catch_exc. """ - # Note: PyCharm will display a error due to the triple dot being used on Callable. - execute = ( - execute_graphql_request - ) # type: Callable[..., Union[Promise[ExecutionResult], ExecutionResult]] - if kwargs.get("return_promise", False): - execute = execute_graphql_request_as_promise - # noinspection PyBroadException try: - execution_result = execute(schema, params, allow_only_query, **kwargs) + if not params.query: + raise HttpQueryError(400, "Must provide query string.") + + # Sanity check query + if not isinstance(params.query, str): + raise HttpQueryError(400, "Unexpected query type.") + + schema_validation_errors = validate_schema(schema) + if schema_validation_errors: + return ExecutionResult(data=None, errors=schema_validation_errors) + + try: + document = parse(params.query) + except GraphQLError as e: + return ExecutionResult(data=None, errors=[e]) + except Exception as e: + e = GraphQLError(str(e), original_error=e) + return ExecutionResult(data=None, errors=[e]) + + if allow_only_query: + operation_ast = get_operation_ast(document, params.operation_name) + if operation_ast: + operation = operation_ast.operation.value + if operation != OperationType.QUERY.value: + raise HttpQueryError( + 405, + f"Can only perform a {operation} operation" + " from a POST request.", + headers={"Allow": "POST"}, + ) + + validation_errors = validate( + schema, document, rules=validation_rules, max_errors=max_errors + ) + if validation_errors: + return ExecutionResult(data=None, errors=validation_errors) + + execution_result = execute( + schema, + document, + variable_values=params.variables, + operation_name=params.operation_name, + is_awaitable=assume_not_awaitable if run_sync else None, + **kwargs, + ) + except catch_exc: return None @@ -342,21 +309,43 @@ def get_response( def format_execution_result( - execution_result, # type: Optional[ExecutionResult] - format_error, # type: Optional[Callable[[Exception], Dict]] -): - # type: (...) -> FormattedResult + execution_result: Optional[ExecutionResult], + format_error: Optional[Callable[[GraphQLError], Dict]] = format_error_default, +) -> FormattedResult: """Format an execution result into a GraphQLResponse. This converts the given execution result into a FormattedResult that contains the ExecutionResult converted to a dictionary and an appropriate status code. """ status_code = 200 + response: Optional[Dict[str, Any]] = None - response = None if execution_result: - if execution_result.invalid: - status_code = 400 - response = execution_result.to_dict(format_error=format_error) + if execution_result.errors: + fe = [format_error(e) for e in execution_result.errors] # type: ignore + response = {"errors": fe} + + if execution_result.errors and any( + not getattr(e, "path", None) for e in execution_result.errors + ): + status_code = 400 + else: + response["data"] = execution_result.data + else: + response = {"data": execution_result.data} return FormattedResult(response, status_code) + + +def _check_jinja(jinja_env: Any) -> None: + try: + from jinja2 import Environment + except ImportError: # pragma: no cover + raise RuntimeError( + "Attempt to set 'jinja_env' to a value other than None while Jinja2 is not installed.\n" + "Please install Jinja2 to render GraphiQL with Jinja2.\n" + "Otherwise set 'jinja_env' to None to use the simple regex renderer." + ) + + if not isinstance(jinja_env, Environment): # pragma: no cover + raise TypeError("'jinja_env' has to be of type jinja2.Environment.") diff --git a/graphql_server/aiohttp/__init__.py b/graphql_server/aiohttp/__init__.py new file mode 100644 index 0000000..8f5beaf --- /dev/null +++ b/graphql_server/aiohttp/__init__.py @@ -0,0 +1,3 @@ +from .graphqlview import GraphQLView + +__all__ = ["GraphQLView"] diff --git a/graphql_server/aiohttp/graphqlview.py b/graphql_server/aiohttp/graphqlview.py new file mode 100644 index 0000000..ea23037 --- /dev/null +++ b/graphql_server/aiohttp/graphqlview.py @@ -0,0 +1,271 @@ +import asyncio +import copy +from collections.abc import MutableMapping +from functools import partial +from typing import List + +from aiohttp import web +from graphql import GraphQLError, specified_rules +from graphql.pyutils import is_awaitable +from graphql.type.schema import GraphQLSchema + +from graphql_server import ( + GraphQLParams, + HttpQueryError, + _check_jinja, + encode_execution_results, + format_error_default, + json_encode, + load_json_body, + run_http_query, +) +from graphql_server.render_graphiql import ( + GraphiQLConfig, + GraphiQLData, + GraphiQLOptions, + render_graphiql_async, +) +from graphql_server.utils import wrap_in_async + + +class GraphQLView: + schema = None + root_value = None + context = None + pretty = False + graphiql = False + graphiql_version = None + graphiql_template = None + graphiql_html_title = None + middleware = None + validation_rules = None + execution_context_class = None + batch = False + jinja_env = None + max_age = 86400 + enable_async = False + subscriptions = None + headers = None + default_query = None + header_editor_enabled = None + should_persist_headers = None + + accepted_methods = ["GET", "POST", "PUT", "DELETE"] + + format_error = staticmethod(format_error_default) + encode = staticmethod(json_encode) + + def __init__(self, **kwargs): + super().__init__() + for key, value in kwargs.items(): + if hasattr(self, key): + setattr(self, key, value) + + if not isinstance(self.schema, GraphQLSchema): + # maybe the GraphQL schema is wrapped in a Graphene schema + self.schema = getattr(self.schema, "graphql_schema", None) + if not isinstance(self.schema, GraphQLSchema): + raise TypeError("A Schema is required to be provided to GraphQLView.") + + if self.jinja_env is not None: + _check_jinja(self.jinja_env) + + def get_root_value(self): + return self.root_value + + def get_context(self, request): + context = ( + copy.copy(self.context) + if self.context is not None and isinstance(self.context, MutableMapping) + else {} + ) + if isinstance(context, MutableMapping) and "request" not in context: + context.update({"request": request}) + return context + + def get_middleware(self): + return self.middleware + + def get_validation_rules(self): + if self.validation_rules is None: + return specified_rules + return self.validation_rules + + def get_execution_context_class(self): + return self.execution_context_class + + @staticmethod + async def parse_body(request): + content_type = request.content_type + # request.text() is the aiohttp equivalent to + # request.body.decode("utf8") + if content_type == "application/graphql": + r_text = await request.text() + return {"query": r_text} + + if content_type == "application/json": + text = await request.text() + return load_json_body(text) + + if content_type in ( + "application/x-www-form-urlencoded", + "multipart/form-data", + ): + # TODO: seems like a multidict would be more appropriate + # than casting it and de-duping variables. Alas, it's what + # graphql-python wants. + return dict(await request.post()) + + return {} + + # TODO: + # use this method to replace flask and sanic + # checks as this is equivalent to `should_display_graphiql` and + # `request_wants_html` methods. + def is_graphiql(self, request): + return all( + [ + self.graphiql, + request.method.lower() == "get", + "raw" not in request.query, + any( + [ + "text/html" in request.headers.get("accept", {}), + "*/*" in request.headers.get("accept", {}), + ] + ), + ] + ) + + # TODO: Same stuff as above method. + def is_pretty(self, request): + return any( + [self.pretty, self.is_graphiql(request), request.query.get("pretty")] + ) + + async def __call__(self, request): + try: + data = await self.parse_body(request) + request_method = request.method.lower() + is_graphiql = self.is_graphiql(request) + is_pretty = self.is_pretty(request) + + # TODO: way better than if-else so better + # implement this too on flask and sanic + if request_method == "options": + return self.process_preflight(request) + + all_params: List[GraphQLParams] + execution_results, all_params = run_http_query( + self.schema, + request_method, + data, + query_data=request.query, + batch_enabled=self.batch, + catch=is_graphiql, + # Execute options + run_sync=not self.enable_async, + root_value=self.get_root_value(), + context_value=self.get_context(request), + middleware=self.get_middleware(), + validation_rules=self.get_validation_rules(), + execution_context_class=self.get_execution_context_class(), + ) + + exec_res = ( + await asyncio.gather( + *( + ex + if ex is not None and is_awaitable(ex) + else wrap_in_async(lambda x: x)(ex) + for ex in execution_results + ) + ) + if self.enable_async + else execution_results + ) + result, status_code = encode_execution_results( + exec_res, + is_batch=isinstance(data, list), + format_error=self.format_error, + encode=partial(self.encode, pretty=is_pretty), + ) + + if is_graphiql: + graphiql_data = GraphiQLData( + result=result, + query=all_params[0].query, + variables=all_params[0].variables, + operation_name=all_params[0].operation_name, + subscription_url=self.subscriptions, + headers=self.headers, + ) + graphiql_config = GraphiQLConfig( + graphiql_version=self.graphiql_version, + graphiql_template=self.graphiql_template, + graphiql_html_title=self.graphiql_html_title, + jinja_env=self.jinja_env, + ) + graphiql_options = GraphiQLOptions( + default_query=self.default_query, + header_editor_enabled=self.header_editor_enabled, + should_persist_headers=self.should_persist_headers, + ) + source = await render_graphiql_async( + data=graphiql_data, config=graphiql_config, options=graphiql_options + ) + return web.Response(text=source, content_type="text/html") + + return web.Response( + text=result, + status=status_code, + content_type="application/json", + ) + + except HttpQueryError as err: + parsed_error = GraphQLError(err.message) + return web.Response( + body=self.encode({"errors": [self.format_error(parsed_error)]}), + status=err.status_code, + headers=err.headers, + content_type="application/json", + ) + + def process_preflight(self, request): + """ + Preflight request support for apollo-client + https://www.w3.org/TR/cors/#resource-preflight-requests + """ + headers = request.headers + origin = headers.get("Origin", "") + method = headers.get("Access-Control-Request-Method", "").upper() + + if method and method in self.accepted_methods: + return web.Response( + status=200, + headers={ + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Methods": ", ".join(self.accepted_methods), + "Access-Control-Max-Age": str(self.max_age), + }, + ) + return web.Response(status=400) + + @classmethod + def attach(cls, app, *, route_path="/graphql", route_name="graphql", **kwargs): + view = cls(**kwargs) + app.router.add_route("*", route_path, _asyncify(view), name=route_name) + + +def _asyncify(handler): + """Return an async version of the given handler. + + This is mainly here because ``aiohttp`` can't infer the async definition of + :py:meth:`.GraphQLView.__call__` and raises a :py:class:`DeprecationWarning` + in tests. Wrapping it into an async function avoids the noisy warning. + """ + + async def _dispatch(request): + return await handler(request) + + return _dispatch diff --git a/graphql_server/error.py b/graphql_server/error.py index b0ca74a..497f121 100644 --- a/graphql_server/error.py +++ b/graphql_server/error.py @@ -16,7 +16,7 @@ def __init__(self, status_code, message=None, is_graphql_error=False, headers=No self.message = message self.is_graphql_error = is_graphql_error self.headers = headers - super(HttpQueryError, self).__init__(message) + super().__init__(message) def __eq__(self, other): """Check whether this HTTP query error is equal to another one.""" diff --git a/graphql_server/flask/__init__.py b/graphql_server/flask/__init__.py new file mode 100644 index 0000000..8f5beaf --- /dev/null +++ b/graphql_server/flask/__init__.py @@ -0,0 +1,3 @@ +from .graphqlview import GraphQLView + +__all__ = ["GraphQLView"] diff --git a/graphql_server/flask/graphqlview.py b/graphql_server/flask/graphqlview.py new file mode 100644 index 0000000..d59124d --- /dev/null +++ b/graphql_server/flask/graphqlview.py @@ -0,0 +1,194 @@ +import copy +from collections.abc import MutableMapping +from functools import partial +from typing import List + +from flask import Response, render_template_string, request +from flask.views import View +from graphql import specified_rules +from graphql.error import GraphQLError +from graphql.type.schema import GraphQLSchema + +from graphql_server import ( + GraphQLParams, + HttpQueryError, + _check_jinja, + encode_execution_results, + format_error_default, + json_encode, + load_json_body, + run_http_query, +) +from graphql_server.render_graphiql import ( + GraphiQLConfig, + GraphiQLData, + GraphiQLOptions, + render_graphiql_sync, +) + + +class GraphQLView(View): + schema = None + root_value = None + context = None + pretty = False + graphiql = False + graphiql_version = None + graphiql_template = None + graphiql_html_title = None + middleware = None + validation_rules = None + execution_context_class = None + batch = False + jinja_env = None + subscriptions = None + headers = None + default_query = None + header_editor_enabled = None + should_persist_headers = None + + methods = ["GET", "POST", "PUT", "DELETE"] + + format_error = staticmethod(format_error_default) + encode = staticmethod(json_encode) + + def __init__(self, **kwargs): + super().__init__() + for key, value in kwargs.items(): + if hasattr(self, key): + setattr(self, key, value) + + if not isinstance(self.schema, GraphQLSchema): + # maybe the GraphQL schema is wrapped in a Graphene schema + self.schema = getattr(self.schema, "graphql_schema", None) + if not isinstance(self.schema, GraphQLSchema): + raise TypeError("A Schema is required to be provided to GraphQLView.") + + if self.jinja_env is not None: + _check_jinja(self.jinja_env) + + def get_root_value(self): + return self.root_value + + def get_context(self): + context = ( + copy.copy(self.context) + if self.context is not None and isinstance(self.context, MutableMapping) + else {} + ) + if isinstance(context, MutableMapping) and "request" not in context: + context.update({"request": request}) + return context + + def get_middleware(self): + return self.middleware + + def get_validation_rules(self): + if self.validation_rules is None: + return specified_rules + return self.validation_rules + + def get_execution_context_class(self): + return self.execution_context_class + + def dispatch_request(self): + try: + request_method = request.method.lower() + data = self.parse_body() + + show_graphiql = request_method == "get" and self.should_display_graphiql() + catch = show_graphiql + + pretty = self.pretty or show_graphiql or request.args.get("pretty") + + all_params: List[GraphQLParams] + execution_results, all_params = run_http_query( + self.schema, + request_method, + data, + query_data=request.args, + batch_enabled=self.batch, + catch=catch, + # Execute options + root_value=self.get_root_value(), + context_value=self.get_context(), + middleware=self.get_middleware(), + validation_rules=self.get_validation_rules(), + execution_context_class=self.get_execution_context_class(), + ) + result, status_code = encode_execution_results( + execution_results, + is_batch=isinstance(data, list), + format_error=self.format_error, + encode=partial(self.encode, pretty=pretty), + ) + + if show_graphiql: + graphiql_data = GraphiQLData( + result=result, + query=all_params[0].query, + variables=all_params[0].variables, + operation_name=all_params[0].operation_name, + subscription_url=self.subscriptions, + headers=self.headers, + ) + graphiql_config = GraphiQLConfig( + graphiql_version=self.graphiql_version, + graphiql_template=self.graphiql_template, + graphiql_html_title=self.graphiql_html_title, + jinja_env=self.jinja_env, + ) + graphiql_options = GraphiQLOptions( + default_query=self.default_query, + header_editor_enabled=self.header_editor_enabled, + should_persist_headers=self.should_persist_headers, + ) + source = render_graphiql_sync( + data=graphiql_data, config=graphiql_config, options=graphiql_options + ) + return render_template_string(source) + + return Response(result, status=status_code, content_type="application/json") + + except HttpQueryError as e: + parsed_error = GraphQLError(e.message) + return Response( + self.encode({"errors": [self.format_error(parsed_error)]}), + status=e.status_code, + headers=e.headers, + content_type="application/json", + ) + + @staticmethod + def parse_body(): + # We use mimetype here since we don't need the other + # information provided by content_type + content_type = request.mimetype + if content_type == "application/graphql": + return {"query": request.data.decode("utf8")} + + elif content_type == "application/json": + return load_json_body(request.data.decode("utf8")) + + elif content_type in ( + "application/x-www-form-urlencoded", + "multipart/form-data", + ): + return request.form + + return {} + + def should_display_graphiql(self): + if not self.graphiql or "raw" in request.args: + return False + + return self.request_wants_html() + + @staticmethod + def request_wants_html(): + best = request.accept_mimetypes.best_match(["application/json", "text/html"]) + return ( + best == "text/html" + and request.accept_mimetypes[best] + > request.accept_mimetypes["application/json"] + ) diff --git a/graphql_server/quart/__init__.py b/graphql_server/quart/__init__.py new file mode 100644 index 0000000..8f5beaf --- /dev/null +++ b/graphql_server/quart/__init__.py @@ -0,0 +1,3 @@ +from .graphqlview import GraphQLView + +__all__ = ["GraphQLView"] diff --git a/graphql_server/quart/graphqlview.py b/graphql_server/quart/graphqlview.py new file mode 100644 index 0000000..8885e5e --- /dev/null +++ b/graphql_server/quart/graphqlview.py @@ -0,0 +1,214 @@ +import asyncio +import copy +from collections.abc import MutableMapping +from functools import partial +from typing import List + +from graphql import specified_rules +from graphql.error import GraphQLError +from graphql.pyutils import is_awaitable +from graphql.type.schema import GraphQLSchema +from quart import Response, render_template_string, request +from quart.views import View + +from graphql_server import ( + GraphQLParams, + HttpQueryError, + _check_jinja, + encode_execution_results, + format_error_default, + json_encode, + load_json_body, + run_http_query, +) +from graphql_server.render_graphiql import ( + GraphiQLConfig, + GraphiQLData, + GraphiQLOptions, + render_graphiql_sync, +) +from graphql_server.utils import wrap_in_async + + +class GraphQLView(View): + schema = None + root_value = None + context = None + pretty = False + graphiql = False + graphiql_version = None + graphiql_template = None + graphiql_html_title = None + middleware = None + validation_rules = None + execution_context_class = None + batch = False + jinja_env = None + enable_async = False + subscriptions = None + headers = None + default_query = None + header_editor_enabled = None + should_persist_headers = None + + methods = ["GET", "POST", "PUT", "DELETE"] + + format_error = staticmethod(format_error_default) + encode = staticmethod(json_encode) + + def __init__(self, **kwargs): + super().__init__() + for key, value in kwargs.items(): + if hasattr(self, key): + setattr(self, key, value) + + if not isinstance(self.schema, GraphQLSchema): + # maybe the GraphQL schema is wrapped in a Graphene schema + self.schema = getattr(self.schema, "graphql_schema", None) + if not isinstance(self.schema, GraphQLSchema): + raise TypeError("A Schema is required to be provided to GraphQLView.") + + if self.jinja_env is not None: + _check_jinja(self.jinja_env) + + def get_root_value(self): + return self.root_value + + def get_context(self): + context = ( + copy.copy(self.context) + if self.context is not None and isinstance(self.context, MutableMapping) + else {} + ) + if isinstance(context, MutableMapping) and "request" not in context: + context.update({"request": request}) + return context + + def get_middleware(self): + return self.middleware + + def get_validation_rules(self): + if self.validation_rules is None: + return specified_rules + return self.validation_rules + + def get_execution_context_class(self): + return self.execution_context_class + + async def dispatch_request(self): + try: + request_method = request.method.lower() + data = await self.parse_body() + + show_graphiql = request_method == "get" and self.should_display_graphiql() + catch = show_graphiql + + pretty = self.pretty or show_graphiql or request.args.get("pretty") + all_params: List[GraphQLParams] + execution_results, all_params = run_http_query( + self.schema, + request_method, + data, + query_data=request.args, + batch_enabled=self.batch, + catch=catch, + # Execute options + run_sync=not self.enable_async, + root_value=self.get_root_value(), + context_value=self.get_context(), + middleware=self.get_middleware(), + validation_rules=self.get_validation_rules(), + execution_context_class=self.get_execution_context_class(), + ) + exec_res = ( + await asyncio.gather( + *( + ex + if ex is not None and is_awaitable(ex) + else wrap_in_async(lambda x: x)(ex) + for ex in execution_results + ) + ) + if self.enable_async + else execution_results + ) + result, status_code = encode_execution_results( + exec_res, + is_batch=isinstance(data, list), + format_error=self.format_error, + encode=partial(self.encode, pretty=pretty), + ) + + if show_graphiql: + graphiql_data = GraphiQLData( + result=result, + query=all_params[0].query, + variables=all_params[0].variables, + operation_name=all_params[0].operation_name, + subscription_url=self.subscriptions, + headers=self.headers, + ) + graphiql_config = GraphiQLConfig( + graphiql_version=self.graphiql_version, + graphiql_template=self.graphiql_template, + graphiql_html_title=self.graphiql_html_title, + jinja_env=self.jinja_env, + ) + graphiql_options = GraphiQLOptions( + default_query=self.default_query, + header_editor_enabled=self.header_editor_enabled, + should_persist_headers=self.should_persist_headers, + ) + source = render_graphiql_sync( + data=graphiql_data, config=graphiql_config, options=graphiql_options + ) + return await render_template_string(source) + + return Response(result, status=status_code, content_type="application/json") + + except HttpQueryError as e: + parsed_error = GraphQLError(e.message) + return Response( + self.encode({"errors": [self.format_error(parsed_error)]}), + status=e.status_code, + headers=e.headers, + content_type="application/json", + ) + + @staticmethod + async def parse_body(): + # We use mimetype here since we don't need the other + # information provided by content_type + content_type = request.mimetype + if content_type == "application/graphql": + refined_data = await request.get_data(as_text=True) + return {"query": refined_data} + + elif content_type == "application/json": + refined_data = await request.get_data(as_text=True) + return load_json_body(refined_data) + + elif content_type == "application/x-www-form-urlencoded": + return await request.form + + # TODO: Fix this check + elif content_type == "multipart/form-data": + return await request.files + + return {} + + def should_display_graphiql(self): + if not self.graphiql or "raw" in request.args: + return False + + return self.request_wants_html() + + @staticmethod + def request_wants_html(): + best = request.accept_mimetypes.best_match(["application/json", "text/html"]) + + return ( + best == "text/html" + and request.accept_mimetypes[best] + > request.accept_mimetypes["application/json"] + ) diff --git a/graphql_server/render_graphiql.py b/graphql_server/render_graphiql.py new file mode 100644 index 0000000..0da06b9 --- /dev/null +++ b/graphql_server/render_graphiql.py @@ -0,0 +1,311 @@ +"""Based on (express-graphql)[https://github.com/graphql/express-graphql/blob/main/src/renderGraphiQL.ts] and +(graphql-ws)[https://github.com/enisdenjo/graphql-ws]""" +import json +import re +from typing import Any, Dict, Optional, Tuple + +# This Environment import is only for type checking purpose, +# and only relevant if rendering GraphiQL with Jinja +try: + from jinja2 import Environment +except ImportError: # pragma: no cover + pass + +from typing_extensions import TypedDict + +GRAPHIQL_VERSION = "2.2.0" + +GRAPHIQL_TEMPLATE = """ + + + + + {{graphiql_html_title}} + + + + + + + + + + + + + +
Loading...
+ + +""" + + +class GraphiQLData(TypedDict): + """GraphiQL ReactDom Data + + Has the following attributes: + + subscription_url + The GraphiQL socket endpoint for using subscriptions in graphql-ws. + headers + An optional GraphQL string to use as the initial displayed request headers, + if None is provided, the stored headers will be used. + """ + + query: Optional[str] + variables: Optional[str] + operation_name: Optional[str] + result: Optional[str] + subscription_url: Optional[str] + headers: Optional[str] + + +class GraphiQLConfig(TypedDict): + """GraphiQL Extra Config + + Has the following attributes: + + graphiql_version + The version of the provided GraphiQL package. + graphiql_template + Inject a Jinja template string to customize GraphiQL. + graphiql_html_title + Replace the default html title on the GraphiQL. + jinja_env + Sets jinja environment to be used to process GraphiQL template. + If Jinja’s async mode is enabled (by enable_async=True), + uses Template.render_async instead of Template.render. + If environment is not set, fallbacks to simple regex-based renderer. + """ + + graphiql_version: Optional[str] + graphiql_template: Optional[str] + graphiql_html_title: Optional[str] + jinja_env: Optional[Environment] + + +class GraphiQLOptions(TypedDict): + """GraphiQL options to display on the UI. + + Has the following attributes: + + default_query + An optional GraphQL string to use when no query is provided and no stored + query exists from a previous session. If None is provided, GraphiQL + will use its own default query. + header_editor_enabled + An optional boolean which enables the header editor when true. + Defaults to false. + should_persist_headers + An optional boolean which enables to persist headers to storage when true. + Defaults to false. + """ + + default_query: Optional[str] + header_editor_enabled: Optional[bool] + should_persist_headers: Optional[bool] + + +def process_var(template: str, name: str, value: Any, jsonify=False) -> str: + pattern = r"{{\s*" + name.replace("\\", r"\\") + r"(\s*|[^}]+)*\s*}}" + if jsonify and value not in ["null", "undefined"]: + value = json.dumps(value) + + value = value.replace("\\", r"\\") + + return re.sub(pattern, value, template) + + +def simple_renderer(template: str, **values: Dict[str, Any]) -> str: + replace = [ + "graphiql_version", + "graphiql_html_title", + "subscription_url", + "header_editor_enabled", + "should_persist_headers", + ] + replace_jsonify = [ + "query", + "result", + "variables", + "operation_name", + "default_query", + "headers", + ] + + for r in replace: + template = process_var(template, r, values.get(r, "")) + + for r in replace_jsonify: + template = process_var(template, r, values.get(r, ""), True) + + return template + + +def _render_graphiql( + data: GraphiQLData, + config: GraphiQLConfig, + options: Optional[GraphiQLOptions] = None, +) -> Tuple[str, Dict[str, Any]]: + """When render_graphiql receives a request which does not Accept JSON, but does + Accept HTML, it may present GraphiQL, the in-browser GraphQL explorer IDE. + When shown, it will be pre-populated with the result of having executed + the requested query. + """ + graphiql_version = config.get("graphiql_version") or GRAPHIQL_VERSION + graphiql_template = config.get("graphiql_template") or GRAPHIQL_TEMPLATE + graphiql_html_title = config.get("graphiql_html_title") or "GraphiQL" + + template_vars: Dict[str, Any] = { + "graphiql_version": graphiql_version, + "graphiql_html_title": graphiql_html_title, + "query": data.get("query"), + "variables": data.get("variables"), + "operation_name": data.get("operation_name"), + "result": data.get("result"), + "subscription_url": data.get("subscription_url") or "", + "headers": data.get("headers") or "", + "default_query": options and options.get("default_query") or "", + "header_editor_enabled": options + and options.get("header_editor_enabled") + or "true", + "should_persist_headers": options + and options.get("should_persist_headers") + or "false", + } + + if template_vars["result"] in ("null"): + template_vars["result"] = None + + return graphiql_template, template_vars + + +async def render_graphiql_async( + data: GraphiQLData, + config: GraphiQLConfig, + options: Optional[GraphiQLOptions] = None, +) -> str: + graphiql_template, template_vars = _render_graphiql(data, config, options) + jinja_env = config.get("jinja_env") + + if jinja_env: + template = jinja_env.from_string(graphiql_template) + if jinja_env.is_async: + source = await template.render_async(**template_vars) + else: + source = template.render(**template_vars) + else: + source = simple_renderer(graphiql_template, **template_vars) + return source + + +def render_graphiql_sync( + data: GraphiQLData, + config: GraphiQLConfig, + options: Optional[GraphiQLOptions] = None, +) -> str: + graphiql_template, template_vars = _render_graphiql(data, config, options) + jinja_env = config.get("jinja_env") + + if jinja_env: + template = jinja_env.from_string(graphiql_template) + source = template.render(**template_vars) + else: + source = simple_renderer(graphiql_template, **template_vars) + return source diff --git a/graphql_server/sanic/__init__.py b/graphql_server/sanic/__init__.py new file mode 100644 index 0000000..8f5beaf --- /dev/null +++ b/graphql_server/sanic/__init__.py @@ -0,0 +1,3 @@ +from .graphqlview import GraphQLView + +__all__ = ["GraphQLView"] diff --git a/graphql_server/sanic/graphqlview.py b/graphql_server/sanic/graphqlview.py new file mode 100644 index 0000000..37e0e0c --- /dev/null +++ b/graphql_server/sanic/graphqlview.py @@ -0,0 +1,246 @@ +import asyncio +import copy +from collections.abc import MutableMapping +from functools import partial +from typing import List + +from graphql import GraphQLError, specified_rules +from graphql.pyutils import is_awaitable +from graphql.type.schema import GraphQLSchema +from sanic.headers import parse_content_header +from sanic.response import HTTPResponse, html +from sanic.views import HTTPMethodView + +from graphql_server import ( + GraphQLParams, + HttpQueryError, + _check_jinja, + encode_execution_results, + format_error_default, + json_encode, + load_json_body, + run_http_query, +) +from graphql_server.render_graphiql import ( + GraphiQLConfig, + GraphiQLData, + GraphiQLOptions, + render_graphiql_async, +) +from graphql_server.utils import wrap_in_async + + +class GraphQLView(HTTPMethodView): + schema = None + root_value = None + context = None + pretty = False + graphiql = False + graphiql_version = None + graphiql_template = None + graphiql_html_title = None + middleware = None + validation_rules = None + execution_context_class = None + batch = False + jinja_env = None + max_age = 86400 + enable_async = False + subscriptions = None + headers = None + default_query = None + header_editor_enabled = None + should_persist_headers = None + + methods = ["GET", "POST", "PUT", "DELETE"] + + format_error = staticmethod(format_error_default) + encode = staticmethod(json_encode) + + def __init__(self, **kwargs): + super().__init__() + for key, value in kwargs.items(): + if hasattr(self, key): + setattr(self, key, value) + + if not isinstance(self.schema, GraphQLSchema): + # maybe the GraphQL schema is wrapped in a Graphene schema + self.schema = getattr(self.schema, "graphql_schema", None) + if not isinstance(self.schema, GraphQLSchema): + raise TypeError("A Schema is required to be provided to GraphQLView.") + + if self.jinja_env is not None: + _check_jinja(self.jinja_env) + + def get_root_value(self): + return self.root_value + + def get_context(self, request): + context = ( + copy.copy(self.context) + if self.context is not None and isinstance(self.context, MutableMapping) + else {} + ) + if isinstance(context, MutableMapping) and "request" not in context: + context.update({"request": request}) + return context + + def get_middleware(self): + return self.middleware + + def get_validation_rules(self): + if self.validation_rules is None: + return specified_rules + return self.validation_rules + + def get_execution_context_class(self): + return self.execution_context_class + + async def __handle_request(self, request, *args, **kwargs): + try: + request_method = request.method.lower() + data = self.parse_body(request) + + show_graphiql = request_method == "get" and self.should_display_graphiql( + request + ) + catch = show_graphiql + + pretty = self.pretty or show_graphiql or request.args.get("pretty") + + if request_method != "options": + all_params: List[GraphQLParams] + execution_results, all_params = run_http_query( + self.schema, + request_method, + data, + query_data=request.args, + batch_enabled=self.batch, + catch=catch, + # Execute options + run_sync=not self.enable_async, + root_value=self.get_root_value(), + context_value=self.get_context(request), + middleware=self.get_middleware(), + validation_rules=self.get_validation_rules(), + execution_context_class=self.get_execution_context_class(), + ) + exec_res = ( + await asyncio.gather( + *( + ex + if ex is not None and is_awaitable(ex) + else wrap_in_async(lambda x: x)(ex) + for ex in execution_results + ) + ) + if self.enable_async + else execution_results + ) + result, status_code = encode_execution_results( + exec_res, + is_batch=isinstance(data, list), + format_error=self.format_error, + encode=partial(self.encode, pretty=pretty), + ) + + if show_graphiql: + graphiql_data = GraphiQLData( + result=result, + query=all_params[0].query, + variables=all_params[0].variables, + operation_name=all_params[0].operation_name, + subscription_url=self.subscriptions, + headers=self.headers, + ) + graphiql_config = GraphiQLConfig( + graphiql_version=self.graphiql_version, + graphiql_template=self.graphiql_template, + graphiql_html_title=self.graphiql_html_title, + jinja_env=self.jinja_env, + ) + graphiql_options = GraphiQLOptions( + default_query=self.default_query, + header_editor_enabled=self.header_editor_enabled, + should_persist_headers=self.should_persist_headers, + ) + source = await render_graphiql_async( + data=graphiql_data, + config=graphiql_config, + options=graphiql_options, + ) + return html(source) + + return HTTPResponse( + result, status=status_code, content_type="application/json" + ) + + else: + return self.process_preflight(request) + + except HttpQueryError as e: + parsed_error = GraphQLError(e.message) + return HTTPResponse( + self.encode({"errors": [self.format_error(parsed_error)]}), + status=e.status_code, + headers=e.headers, + content_type="application/json", + ) + + get = post = put = head = options = patch = delete = __handle_request + + # noinspection PyBroadException + def parse_body(self, request): + content_type = self.get_mime_type(request) + if content_type == "application/graphql": + return {"query": request.body.decode("utf8")} + + elif content_type == "application/json": + return load_json_body(request.body.decode("utf8")) + + elif content_type in ( + "application/x-www-form-urlencoded", + "multipart/form-data", + ): + return request.form + + return {} + + @staticmethod + def get_mime_type(request): + # We use mime type here since we don't need the other + # information provided by content_type + if "content-type" not in request.headers: + return None + + mime_type, _ = parse_content_header(request.headers["content-type"]) + return mime_type + + def should_display_graphiql(self, request): + if not self.graphiql or "raw" in request.args: + return False + + return self.request_wants_html(request) + + @staticmethod + def request_wants_html(request): + accept = request.headers.get("accept", {}) + return "text/html" in accept or "*/*" in accept + + def process_preflight(self, request): + """Preflight request support for apollo-client + https://www.w3.org/TR/cors/#resource-preflight-requests""" + origin = request.headers.get("Origin", "") + method = request.headers.get("Access-Control-Request-Method", "").upper() + + if method and method in self.methods: + return HTTPResponse( + status=200, + headers={ + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Methods": ", ".join(self.methods), + "Access-Control-Max-Age": str(self.max_age), + }, + ) + else: + return HTTPResponse(status=400) diff --git a/graphql_server/utils.py b/graphql_server/utils.py new file mode 100644 index 0000000..d1920d2 --- /dev/null +++ b/graphql_server/utils.py @@ -0,0 +1,25 @@ +import sys +from typing import Awaitable, Callable, TypeVar + +if sys.version_info >= (3, 10): + from typing import ParamSpec +else: # pragma: no cover + from typing_extensions import ParamSpec + + +__all__ = ["wrap_in_async"] + +P = ParamSpec("P") +R = TypeVar("R") + + +def wrap_in_async(f: Callable[P, R]) -> Callable[P, Awaitable[R]]: + """Convert a sync callable (normal def or lambda) to a coroutine (async def). + + This is similar to asyncio.coroutine which was deprecated in Python 3.8. + """ + + async def f_async(*args: P.args, **kwargs: P.kwargs) -> R: + return f(*args, **kwargs) + + return f_async diff --git a/graphql_server/version.py b/graphql_server/version.py new file mode 100644 index 0000000..138d67d --- /dev/null +++ b/graphql_server/version.py @@ -0,0 +1,15 @@ +__all__ = ["version", "version_info"] + + +version = "3.0.0b7" +version_info = (3, 0, 0, "beta", 7) +# version_info has the same format as django.VERSION +# https://github.com/django/django/blob/4a5048b036fd9e965515e31fdd70b0af72655cba/django/utils/version.py#L22 +# +# examples +# "3.0.0" -> (3, 0, 0, "final", 0) +# "3.0.0rc1" -> (3, 0, 0, "rc", 1) +# "3.0.0b7" -> (3, 0, 0, "beta", 7) +# "3.0.0a2" -> (3, 0, 0, "alpha", 2) +# +# also see tests/test_version.py diff --git a/graphql_server/webob/__init__.py b/graphql_server/webob/__init__.py new file mode 100644 index 0000000..8f5beaf --- /dev/null +++ b/graphql_server/webob/__init__.py @@ -0,0 +1,3 @@ +from .graphqlview import GraphQLView + +__all__ = ["GraphQLView"] diff --git a/graphql_server/webob/graphqlview.py b/graphql_server/webob/graphqlview.py new file mode 100644 index 0000000..78ff48a --- /dev/null +++ b/graphql_server/webob/graphqlview.py @@ -0,0 +1,204 @@ +import copy +from collections.abc import MutableMapping +from functools import partial +from typing import List + +from graphql import specified_rules +from graphql.error import GraphQLError +from graphql.type.schema import GraphQLSchema +from webob import Response + +from graphql_server import ( + GraphQLParams, + HttpQueryError, + _check_jinja, + encode_execution_results, + format_error_default, + json_encode, + load_json_body, + run_http_query, +) +from graphql_server.render_graphiql import ( + GraphiQLConfig, + GraphiQLData, + GraphiQLOptions, + render_graphiql_sync, +) + + +class GraphQLView: + schema = None + root_value = None + context = None + pretty = False + graphiql = False + graphiql_version = None + graphiql_template = None + graphiql_html_title = None + middleware = None + validation_rules = None + execution_context_class = None + batch = False + jinja_env = None + enable_async = False + subscriptions = None + headers = None + default_query = None + header_editor_enabled = None + should_persist_headers = None + charset = "UTF-8" + + format_error = staticmethod(format_error_default) + encode = staticmethod(json_encode) + + def __init__(self, **kwargs): + super().__init__() + for key, value in kwargs.items(): + if hasattr(self, key): + setattr(self, key, value) + + if not isinstance(self.schema, GraphQLSchema): + # maybe the GraphQL schema is wrapped in a Graphene schema + self.schema = getattr(self.schema, "graphql_schema", None) + if not isinstance(self.schema, GraphQLSchema): + raise TypeError("A Schema is required to be provided to GraphQLView.") + + if self.jinja_env is not None: + _check_jinja(self.jinja_env) + + def get_root_value(self): + return self.root_value + + def get_context(self, request): + context = ( + copy.copy(self.context) + if self.context is not None and isinstance(self.context, MutableMapping) + else {} + ) + if isinstance(context, MutableMapping) and "request" not in context: + context.update({"request": request}) + return context + + def get_middleware(self): + return self.middleware + + def get_validation_rules(self): + if self.validation_rules is None: + return specified_rules + return self.validation_rules + + def get_execution_context_class(self): + return self.execution_context_class + + def dispatch_request(self, request): + try: + request_method = request.method.lower() + data = self.parse_body(request) + + show_graphiql = request_method == "get" and self.should_display_graphiql( + request + ) + catch = show_graphiql + + pretty = self.pretty or show_graphiql or request.params.get("pretty") + + all_params: List[GraphQLParams] + execution_results, all_params = run_http_query( + self.schema, + request_method, + data, + query_data=request.params, + batch_enabled=self.batch, + catch=catch, + # Execute options + run_sync=not self.enable_async, + root_value=self.get_root_value(), + context_value=self.get_context(request), + middleware=self.get_middleware(), + validation_rules=self.get_validation_rules(), + execution_context_class=self.get_execution_context_class(), + ) + result, status_code = encode_execution_results( + execution_results, + is_batch=isinstance(data, list), + format_error=self.format_error, + encode=partial(self.encode, pretty=pretty), + ) + + if show_graphiql: + graphiql_data = GraphiQLData( + result=result, + query=all_params[0].query, + variables=all_params[0].variables, + operation_name=all_params[0].operation_name, + subscription_url=self.subscriptions, + headers=self.headers, + ) + graphiql_config = GraphiQLConfig( + graphiql_version=self.graphiql_version, + graphiql_template=self.graphiql_template, + graphiql_html_title=self.graphiql_html_title, + jinja_env=self.jinja_env, + ) + graphiql_options = GraphiQLOptions( + default_query=self.default_query, + header_editor_enabled=self.header_editor_enabled, + should_persist_headers=self.should_persist_headers, + ) + return Response( + render_graphiql_sync( + data=graphiql_data, + config=graphiql_config, + options=graphiql_options, + ), + charset=self.charset, + content_type="text/html", + ) + + return Response( + result, + status=status_code, + charset=self.charset, + content_type="application/json", + ) + + except HttpQueryError as e: + parsed_error = GraphQLError(e.message) + return Response( + self.encode({"errors": [self.format_error(parsed_error)]}), + status=e.status_code, + charset=self.charset, + headers=e.headers or {}, + content_type="application/json", + ) + + # WebOb + @staticmethod + def parse_body(request): + # We use mimetype here since we don't need the other + # information provided by content_type + content_type = request.content_type + if content_type == "application/graphql": + return {"query": request.body.decode("utf8")} + + elif content_type == "application/json": + return load_json_body(request.body.decode("utf8")) + + elif content_type in ( + "application/x-www-form-urlencoded", + "multipart/form-data", + ): + return request.params + + return {} + + def should_display_graphiql(self, request): + if not self.graphiql or "raw" in request.params: + return False + + return self.request_wants_html(request) + + @staticmethod + def request_wants_html(request): + best = request.accept.best_match(["application/json", "text/html"]) + return best == "text/html" diff --git a/setup.cfg b/setup.cfg index 70e1f4a..9888367 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,12 +1,6 @@ -[flake8] -exclude = docs -max-line-length = 88 - -[isort] -known_first_party=graphql_server - [tool:pytest] norecursedirs = venv .venv .tox .git .cache .mypy_cache .pytest_cache +markers = asyncio [bdist_wheel] universal=1 diff --git a/setup.py b/setup.py index a6416c0..3531daa 100644 --- a/setup.py +++ b/setup.py @@ -1,55 +1,92 @@ -from setuptools import setup, find_packages +from re import search + +from setuptools import find_packages, setup install_requires = [ - "graphql-core>=2.3,<3", - "promise>=2.3,<3", + "graphql-core>=3.2,<3.3", + "typing-extensions>=4,<5", ] tests_requires = [ - "pytest==4.6.9", - "pytest-cov==2.8.1" + "pytest>=7.2,<8", + "pytest-asyncio>=0.20,<1", + "pytest-cov>=4,<5", + "Jinja2>=3.1,<4", + "sanic-testing>=22.3,<24", + "packaging==23.2", ] dev_requires = [ - 'flake8==3.7.9', - 'isort<4.0.0', - 'black==19.10b0', - 'mypy==0.761', - 'check-manifest>=0.40,<1', + "mypy>=1.6,<1.7", ] + tests_requires +install_flask_requires = [ + "flask>=1,<4", +] + +install_sanic_requires = [ + "sanic>=21.12,<24", +] + +install_webob_requires = [ + "webob>=1.8.7,<2", +] + +install_aiohttp_requires = [ + "aiohttp>=3.8,<4", +] + +install_quart_requires = ["quart>=0.15,<1"] + +install_all_requires = ( + install_requires + + install_flask_requires + + install_sanic_requires + + install_webob_requires + + install_aiohttp_requires + + install_quart_requires +) + +with open("graphql_server/version.py") as version_file: + version = search('version = "(.*)"', version_file.read()).group(1) + +with open("README.md", encoding="utf-8") as readme_file: + readme = readme_file.read() + setup( - name="graphql-server-core", - version="2.0.0", + name="graphql-server", + version=version, description="GraphQL Server tools for powering your server", - long_description=open("README.md").read(), + long_description=readme, long_description_content_type="text/markdown", - url="https://github.com/graphql-python/graphql-server-core", - download_url="https://github.com/graphql-python/graphql-server-core/releases", + url="https://github.com/graphql-python/graphql-server", + download_url="https://github.com/graphql-python/graphql-server/releases", author="Syrus Akbary", author_email="me@syrusakbary.com", license="MIT", classifiers=[ - "Development Status :: 5 - Production/Stable", + "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "Topic :: Software Development :: Libraries", - "Programming Language :: Python :: 2", - "Programming Language :: Python :: 2.7", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.5", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: Implementation :: PyPy", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "License :: OSI Approved :: MIT License", ], keywords="api graphql protocol rest", - packages=find_packages(exclude=["tests"]), + packages=find_packages(include=["graphql_server*"]), install_requires=install_requires, - tests_require=tests_requires, + tests_require=install_all_requires + tests_requires, extras_require={ - 'test': tests_requires, - 'dev': dev_requires, + "all": install_all_requires, + "test": install_all_requires + tests_requires, + "dev": install_all_requires + dev_requires, + "flask": install_flask_requires, + "sanic": install_sanic_requires, + "webob": install_webob_requires, + "aiohttp": install_aiohttp_requires, + "quart": install_quart_requires, }, include_package_data=True, zip_safe=False, diff --git a/tests/__init__.py b/tests/__init__.py index 2a8fe60..ad617d8 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1 @@ -"""GraphQL-Server-Core Tests""" +"""GraphQL-Server Tests""" diff --git a/tests/aiohttp/__init__.py b/tests/aiohttp/__init__.py new file mode 100644 index 0000000..943d58f --- /dev/null +++ b/tests/aiohttp/__init__.py @@ -0,0 +1 @@ +# aiohttp-graphql tests diff --git a/tests/aiohttp/app.py b/tests/aiohttp/app.py new file mode 100644 index 0000000..c4b5110 --- /dev/null +++ b/tests/aiohttp/app.py @@ -0,0 +1,18 @@ +from urllib.parse import urlencode + +from aiohttp import web + +from graphql_server.aiohttp import GraphQLView + +from .schema import Schema + + +def create_app(schema=Schema, **kwargs): + app = web.Application() + # Only needed to silence aiohttp deprecation warnings + GraphQLView.attach(app, schema=schema, **kwargs) + return app + + +def url_string(url="/graphql", **url_params): + return f"{url}?{urlencode(url_params)}" if url_params else url diff --git a/tests/aiohttp/conftest.py b/tests/aiohttp/conftest.py new file mode 100644 index 0000000..8adfd0a --- /dev/null +++ b/tests/aiohttp/conftest.py @@ -0,0 +1,18 @@ +import pytest +import pytest_asyncio +from aiohttp.test_utils import TestClient, TestServer + +from .app import create_app + + +@pytest.fixture +def app(): + return create_app() + + +@pytest_asyncio.fixture +async def client(app): + client = TestClient(TestServer(app)) + await client.start_server() + yield client + await client.close() diff --git a/tests/aiohttp/schema.py b/tests/aiohttp/schema.py new file mode 100644 index 0000000..e94088f --- /dev/null +++ b/tests/aiohttp/schema.py @@ -0,0 +1,118 @@ +import asyncio + +from graphql.type.definition import ( + GraphQLArgument, + GraphQLField, + GraphQLNonNull, + GraphQLObjectType, +) +from graphql.type.scalars import GraphQLString +from graphql.type.schema import GraphQLSchema + + +def resolve_raises(*_): + raise Exception("Throws!") + + +# Sync schema +QueryRootType = GraphQLObjectType( + name="QueryRoot", + fields={ + "thrower": GraphQLField( + GraphQLNonNull(GraphQLString), + resolve=resolve_raises, + ), + "request": GraphQLField( + GraphQLNonNull(GraphQLString), + resolve=lambda obj, info, *args: info.context["request"].query.get("q"), + ), + "context": GraphQLField( + GraphQLObjectType( + name="context", + fields={ + "session": GraphQLField(GraphQLString), + "request": GraphQLField( + GraphQLNonNull(GraphQLString), + resolve=lambda obj, info: info.context["request"], + ), + "property": GraphQLField( + GraphQLString, resolve=lambda obj, info: info.context.property + ), + }, + ), + resolve=lambda obj, info: info.context, + ), + "test": GraphQLField( + type_=GraphQLString, + args={"who": GraphQLArgument(GraphQLString)}, + resolve=lambda obj, info, who=None: "Hello %s" % (who or "World"), + ), + }, +) + + +MutationRootType = GraphQLObjectType( + name="MutationRoot", + fields={ + "writeTest": GraphQLField( + type_=QueryRootType, resolve=lambda *args: QueryRootType + ) + }, +) + +SubscriptionsRootType = GraphQLObjectType( + name="SubscriptionsRoot", + fields={ + "subscriptionsTest": GraphQLField( + type_=QueryRootType, resolve=lambda *args: QueryRootType + ) + }, +) + +Schema = GraphQLSchema(QueryRootType, MutationRootType, SubscriptionsRootType) + + +# Schema with async methods +async def resolver_field_async_1(_obj, info): + await asyncio.sleep(0.001) + return "hey" + + +async def resolver_field_async_2(_obj, info): + await asyncio.sleep(0.003) + return "hey2" + + +def resolver_field_sync(_obj, info): + return "hey3" + + +AsyncQueryType = GraphQLObjectType( + "AsyncQueryType", + { + "a": GraphQLField(GraphQLString, resolve=resolver_field_async_1), + "b": GraphQLField(GraphQLString, resolve=resolver_field_async_2), + "c": GraphQLField(GraphQLString, resolve=resolver_field_sync), + }, +) + + +def resolver_field_sync_1(_obj, info): + return "synced_one" + + +def resolver_field_sync_2(_obj, info): + return "synced_two" + + +SyncQueryType = GraphQLObjectType( + "SyncQueryType", + { + "a": GraphQLField(GraphQLString, resolve=resolver_field_sync_1), + "b": GraphQLField(GraphQLString, resolve=resolver_field_sync_2), + }, +) + + +AsyncSchema = GraphQLSchema(AsyncQueryType) +SyncSchema = GraphQLSchema(SyncQueryType) diff --git a/tests/aiohttp/test_graphiqlview.py b/tests/aiohttp/test_graphiqlview.py new file mode 100644 index 0000000..b3f39cd --- /dev/null +++ b/tests/aiohttp/test_graphiqlview.py @@ -0,0 +1,139 @@ +import pytest +from jinja2 import Environment + +from .app import create_app, url_string +from .schema import AsyncSchema, SyncSchema + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "app", + [ + create_app(graphiql=True), + create_app(graphiql=True, jinja_env=Environment()), + create_app(graphiql=True, jinja_env=Environment(enable_async=True)), + ], +) +async def test_graphiql_is_enabled(app, client): + response = await client.get( + url_string(query="{test}"), + headers={"Accept": "text/html"}, + ) + assert response.status == 200 + + pretty_response = ( + "{\n" + ' "data": {\n' + ' "test": "Hello World"\n' + " }\n" + "}".replace('"', '\\"').replace("\n", "\\n") + ) # fmt: skip + + assert pretty_response in await response.text() + + +@pytest.mark.asyncio +async def test_graphiql_html_is_not_accepted(client): + response = await client.get( + "/graphql", + headers={"Accept": "application/json"}, + ) + assert response.status == 400 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "app", + [create_app(graphiql=True), create_app(graphiql=True, jinja_env=Environment())], +) +async def test_graphiql_get_mutation(app, client): + response = await client.get( + url_string(query="mutation TestMutation { writeTest { test } }"), + headers={"Accept": "text/html"}, + ) + assert response.status == 200 + assert "response: null" in await response.text() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "app", + [create_app(graphiql=True), create_app(graphiql=True, jinja_env=Environment())], +) +async def test_graphiql_get_subscriptions(app, client): + response = await client.get( + url_string( + query="subscription TestSubscriptions { subscriptionsTest { test } }" + ), + headers={"Accept": "text/html"}, + ) + assert response.status == 200 + assert "response: null" in await response.text() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "app", + [ + create_app(schema=AsyncSchema, enable_async=True, graphiql=True), + create_app( + schema=AsyncSchema, + enable_async=True, + graphiql=True, + jinja_env=Environment(), + ), + ], +) +async def test_graphiql_enabled_async_schema(app, client): + response = await client.get( + url_string(query="{a,b,c}"), + headers={"Accept": "text/html"}, + ) + + expected_response = ( + ( + "{\n" + ' "data": {\n' + ' "a": "hey",\n' + ' "b": "hey2",\n' + ' "c": "hey3"\n' + " }\n" + "}" + ) + .replace('"', '\\"') + .replace("\n", "\\n") + ) + assert response.status == 200 + assert expected_response in await response.text() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "app", + [ + create_app(schema=SyncSchema, enable_async=True, graphiql=True), + create_app( + schema=SyncSchema, enable_async=True, graphiql=True, jinja_env=Environment() + ), + ], +) +async def test_graphiql_enabled_sync_schema(app, client): + response = await client.get( + url_string(query="{a,b}"), + headers={"Accept": "text/html"}, + ) + + expected_response = ( + ( + "{\n" + ' "data": {\n' + ' "a": "synced_one",\n' + ' "b": "synced_two"\n' + " }\n" + "}" + ) + .replace('"', '\\"') + .replace("\n", "\\n") + ) + assert response.status == 200 + assert expected_response in await response.text() diff --git a/tests/aiohttp/test_graphqlview.py b/tests/aiohttp/test_graphqlview.py new file mode 100644 index 0000000..3009426 --- /dev/null +++ b/tests/aiohttp/test_graphqlview.py @@ -0,0 +1,702 @@ +import json +from urllib.parse import urlencode + +import pytest +from aiohttp import FormData + +from ..utils import RepeatExecutionContext +from .app import create_app, url_string +from .schema import AsyncSchema + + +@pytest.mark.asyncio +async def test_allows_get_with_query_param(client): + response = await client.get(url_string(query="{test}")) + + assert response.status == 200 + assert await response.json() == {"data": {"test": "Hello World"}} + + +@pytest.mark.asyncio +async def test_allows_get_with_variable_values(client): + response = await client.get( + url_string( + query="query helloWho($who: String) { test(who: $who) }", + variables=json.dumps({"who": "Dolly"}), + ) + ) + + assert response.status == 200 + assert await response.json() == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_allows_get_with_operation_name(client): + response = await client.get( + url_string( + query=""" + query helloYou { test(who: "You"), ...shared } + query helloWorld { test(who: "World"), ...shared } + query helloDolly { test(who: "Dolly"), ...shared } + fragment shared on QueryRoot { + shared: test(who: "Everyone") + } + """, + operationName="helloWorld", + ) + ) + + assert response.status == 200 + assert await response.json() == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +@pytest.mark.asyncio +async def test_reports_validation_errors(client): + response = await client.get(url_string(query="{ test, unknownOne, unknownTwo }")) + + assert response.status == 400 + assert await response.json() == { + "errors": [ + { + "message": "Cannot query field 'unknownOne' on type 'QueryRoot'.", + "locations": [{"line": 1, "column": 9}], + }, + { + "message": "Cannot query field 'unknownTwo' on type 'QueryRoot'.", + "locations": [{"line": 1, "column": 21}], + }, + ], + } + + +@pytest.mark.asyncio +async def test_errors_when_missing_operation_name(client): + response = await client.get( + url_string( + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + subscription TestSubscriptions { subscriptionsTest { test } } + """ + ) + ) + + assert response.status == 400 + assert await response.json() == { + "errors": [ + { + "message": ( + "Must provide operation name if query contains multiple " + "operations." + ), + }, + ] + } + + +@pytest.mark.asyncio +async def test_errors_when_sending_a_mutation_via_get(client): + response = await client.get( + url_string( + query=""" + mutation TestMutation { writeTest { test } } + """ + ) + ) + assert response.status == 405 + assert await response.json() == { + "errors": [ + { + "message": "Can only perform a mutation operation from a POST request.", + }, + ], + } + + +@pytest.mark.asyncio +async def test_errors_when_selecting_a_mutation_within_a_get(client): + response = await client.get( + url_string( + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + """, + operationName="TestMutation", + ) + ) + + assert response.status == 405 + assert await response.json() == { + "errors": [ + { + "message": "Can only perform a mutation operation from a POST request.", + }, + ], + } + + +@pytest.mark.asyncio +async def test_errors_when_selecting_a_subscription_within_a_get(client): + response = await client.get( + url_string( + query=""" + subscription TestSubscriptions { subscriptionsTest { test } } + """, + operationName="TestSubscriptions", + ) + ) + + assert response.status == 405 + assert await response.json() == { + "errors": [ + { + "message": "Can only perform a subscription operation" + " from a POST request.", + }, + ], + } + + +@pytest.mark.asyncio +async def test_allows_mutation_to_exist_within_a_get(client): + response = await client.get( + url_string( + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + """, + operationName="TestQuery", + ) + ) + + assert response.status == 200 + assert await response.json() == {"data": {"test": "Hello World"}} + + +@pytest.mark.asyncio +async def test_allows_post_with_json_encoding(client): + response = await client.post( + "/graphql", + data=json.dumps({"query": "{test}"}), + headers={"content-type": "application/json"}, + ) + + assert await response.json() == {"data": {"test": "Hello World"}} + assert response.status == 200 + + +@pytest.mark.asyncio +async def test_allows_sending_a_mutation_via_post(client): + response = await client.post( + "/graphql", + data=json.dumps( + { + "query": "mutation TestMutation { writeTest { test } }", + } + ), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert await response.json() == {"data": {"writeTest": {"test": "Hello World"}}} + + +@pytest.mark.asyncio +async def test_allows_post_with_url_encoding(client): + data = FormData() + data.add_field("query", "{test}") + response = await client.post( + "/graphql", + data=data(), + headers={"content-type": "application/x-www-form-urlencoded"}, + ) + + assert await response.json() == {"data": {"test": "Hello World"}} + assert response.status == 200 + + +@pytest.mark.asyncio +async def test_supports_post_json_query_with_string_variables(client): + response = await client.post( + "/graphql", + data=json.dumps( + { + "query": "query helloWho($who: String){ test(who: $who) }", + "variables": json.dumps({"who": "Dolly"}), + } + ), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert await response.json() == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_supports_post_json_query_with_json_variables(client): + response = await client.post( + "/graphql", + data=json.dumps( + { + "query": "query helloWho($who: String){ test(who: $who) }", + "variables": {"who": "Dolly"}, + } + ), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert await response.json() == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_supports_post_url_encoded_query_with_string_variables(client): + response = await client.post( + "/graphql", + data=urlencode( + { + "query": "query helloWho($who: String){ test(who: $who) }", + "variables": json.dumps({"who": "Dolly"}), + }, + ), + headers={"content-type": "application/x-www-form-urlencoded"}, + ) + + assert response.status == 200 + assert await response.json() == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_supports_post_json_quey_with_get_variable_values(client): + response = await client.post( + url_string(variables=json.dumps({"who": "Dolly"})), + data=json.dumps( + { + "query": "query helloWho($who: String){ test(who: $who) }", + } + ), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert await response.json() == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_post_url_encoded_query_with_get_variable_values(client): + response = await client.post( + url_string(variables=json.dumps({"who": "Dolly"})), + data=urlencode( + { + "query": "query helloWho($who: String){ test(who: $who) }", + } + ), + headers={"content-type": "application/x-www-form-urlencoded"}, + ) + + assert response.status == 200 + assert await response.json() == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_supports_post_raw_text_query_with_get_variable_values(client): + response = await client.post( + url_string(variables=json.dumps({"who": "Dolly"})), + data="query helloWho($who: String){ test(who: $who) }", + headers={"content-type": "application/graphql"}, + ) + + assert response.status == 200 + assert await response.json() == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_allows_post_with_operation_name(client): + response = await client.post( + "/graphql", + data=json.dumps( + { + "query": """ + query helloYou { test(who: "You"), ...shared } + query helloWorld { test(who: "World"), ...shared } + query helloDolly { test(who: "Dolly"), ...shared } + fragment shared on QueryRoot { + shared: test(who: "Everyone") + } + """, + "operationName": "helloWorld", + } + ), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert await response.json() == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +@pytest.mark.asyncio +async def test_allows_post_with_get_operation_name(client): + response = await client.post( + url_string(operationName="helloWorld"), + data=""" + query helloYou { test(who: "You"), ...shared } + query helloWorld { test(who: "World"), ...shared } + query helloDolly { test(who: "Dolly"), ...shared } + fragment shared on QueryRoot { + shared: test(who: "Everyone") + } + """, + headers={"content-type": "application/graphql"}, + ) + + assert response.status == 200 + assert await response.json() == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +@pytest.mark.asyncio +async def test_supports_pretty_printing(client): + response = await client.get(url_string(query="{test}", pretty="1")) + + text = await response.text() + assert text == "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" + + +@pytest.mark.asyncio +async def test_not_pretty_by_default(client): + response = await client.get(url_string(query="{test}")) + + assert await response.text() == '{"data":{"test":"Hello World"}}' + + +@pytest.mark.asyncio +async def test_supports_pretty_printing_by_request(client): + response = await client.get(url_string(query="{test}", pretty="1")) + + assert await response.text() == ( + "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" + ) + + +@pytest.mark.asyncio +async def test_handles_field_errors_caught_by_graphql(client): + response = await client.get(url_string(query="{thrower}")) + assert response.status == 200 + assert await response.json() == { + "data": None, + "errors": [ + { + "locations": [{"column": 2, "line": 1}], + "message": "Throws!", + "path": ["thrower"], + } + ], + } + + +@pytest.mark.asyncio +async def test_handles_syntax_errors_caught_by_graphql(client): + response = await client.get(url_string(query="syntaxerror")) + + assert response.status == 400 + assert await response.json() == { + "errors": [ + { + "locations": [{"column": 1, "line": 1}], + "message": "Syntax Error: Unexpected Name 'syntaxerror'.", + }, + ], + } + + +@pytest.mark.asyncio +async def test_handles_errors_caused_by_a_lack_of_query(client): + response = await client.get("/graphql") + + assert response.status == 400 + assert await response.json() == { + "errors": [{"message": "Must provide query string."}] + } + + +@pytest.mark.asyncio +async def test_handles_batch_correctly_if_is_disabled(client): + response = await client.post( + "/graphql", + data="[]", + headers={"content-type": "application/json"}, + ) + + assert response.status == 400 + assert await response.json() == { + "errors": [ + { + "message": "Batch GraphQL requests are not enabled.", + } + ] + } + + +@pytest.mark.asyncio +async def test_handles_incomplete_json_bodies(client): + response = await client.post( + "/graphql", + data='{"query":', + headers={"content-type": "application/json"}, + ) + + assert response.status == 400 + assert await response.json() == { + "errors": [ + { + "message": "POST body sent invalid JSON.", + } + ] + } + + +@pytest.mark.asyncio +async def test_handles_plain_post_text(client): + response = await client.post( + url_string(variables=json.dumps({"who": "Dolly"})), + data="query helloWho($who: String){ test(who: $who) }", + headers={"content-type": "text/plain"}, + ) + assert response.status == 400 + assert await response.json() == { + "errors": [{"message": "Must provide query string."}] + } + + +@pytest.mark.asyncio +async def test_handles_poorly_formed_variables(client): + response = await client.get( + url_string( + query="query helloWho($who: String){ test(who: $who) }", variables="who:You" + ), + ) + assert response.status == 400 + assert await response.json() == { + "errors": [{"message": "Variables are invalid JSON."}] + } + + +@pytest.mark.asyncio +async def test_handles_unsupported_http_methods(client): + response = await client.put(url_string(query="{test}")) + assert response.status == 405 + assert response.headers["Allow"] in ["GET, POST", "HEAD, GET, POST, OPTIONS"] + assert await response.json() == { + "errors": [ + { + "message": "GraphQL only supports GET and POST requests.", + } + ] + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app()]) +async def test_passes_request_into_request_context(app, client): + response = await client.get(url_string(query="{request}", q="testing")) + + assert response.status == 200 + assert await response.json() == { + "data": {"request": "testing"}, + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(context={"session": "CUSTOM CONTEXT"})]) +async def test_passes_custom_context_into_context(app, client): + response = await client.get(url_string(query="{context { session request }}")) + + _json = await response.json() + assert response.status == 200 + assert "data" in _json + assert "session" in _json["data"]["context"] + assert "request" in _json["data"]["context"] + assert "CUSTOM CONTEXT" in _json["data"]["context"]["session"] + assert "Request" in _json["data"]["context"]["request"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(context="CUSTOM CONTEXT")]) +async def test_context_remapped_if_not_mapping(app, client): + response = await client.get(url_string(query="{context { session request }}")) + + _json = await response.json() + assert response.status == 200 + assert "data" in _json + assert "session" in _json["data"]["context"] + assert "request" in _json["data"]["context"] + assert "CUSTOM CONTEXT" not in _json["data"]["context"]["request"] + assert "Request" in _json["data"]["context"]["request"] + + +class CustomContext(dict): + property = "A custom property" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(context=CustomContext())]) +async def test_allow_empty_custom_context(app, client): + response = await client.get(url_string(query="{context { property request }}")) + + _json = await response.json() + assert response.status == 200 + assert "data" in _json + assert "request" in _json["data"]["context"] + assert "property" in _json["data"]["context"] + assert "A custom property" == _json["data"]["context"]["property"] + assert "Request" in _json["data"]["context"]["request"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(context={"request": "test"})]) +async def test_request_not_replaced(app, client): + response = await client.get(url_string(query="{context { request }}")) + + _json = await response.json() + assert response.status == 200 + assert _json["data"]["context"]["request"] == "test" + + +@pytest.mark.asyncio +async def test_post_multipart_data(client): + query = "mutation TestMutation { writeTest { test } }" + + data = ( + "------aiohttpgraphql\r\n" + 'Content-Disposition: form-data; name="query"\r\n' + "\r\n" + query + "\r\n" + "------aiohttpgraphql--\r\n" + "Content-Type: text/plain; charset=utf-8\r\n" + 'Content-Disposition: form-data; name="file"; filename="text1.txt";' + " filename*=utf-8''text1.txt\r\n" + "\r\n" + "\r\n" + "------aiohttpgraphql--\r\n" + ) + + response = await client.post( + "/graphql", + data=data, + headers={"content-type": "multipart/form-data; boundary=----aiohttpgraphql"}, + ) + + assert response.status == 200 + assert await response.json() == {"data": {"writeTest": {"test": "Hello World"}}} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(batch=True)]) +async def test_batch_allows_post_with_json_encoding(app, client): + response = await client.post( + "/graphql", + data=json.dumps([{"id": 1, "query": "{test}"}]), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert await response.json() == [{"data": {"test": "Hello World"}}] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(batch=True)]) +async def test_batch_supports_post_json_query_with_json_variables(app, client): + response = await client.post( + "/graphql", + data=json.dumps( + [ + { + "id": 1, + "query": "query helloWho($who: String){ test(who: $who) }", + "variables": {"who": "Dolly"}, + } + ] + ), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert await response.json() == [{"data": {"test": "Hello Dolly"}}] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(batch=True)]) +async def test_batch_allows_post_with_operation_name(app, client): + response = await client.post( + "/graphql", + data=json.dumps( + [ + { + "id": 1, + "query": """ + query helloYou { test(who: "You"), ...shared } + query helloWorld { test(who: "World"), ...shared } + query helloDolly { test(who: "Dolly"), ...shared } + fragment shared on QueryRoot { + shared: test(who: "Everyone") + } + """, + "operationName": "helloWorld", + } + ] + ), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert await response.json() == [ + {"data": {"test": "Hello World", "shared": "Hello Everyone"}} + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(schema=AsyncSchema, enable_async=True)]) +async def test_async_schema(app, client): + response = await client.get(url_string(query="{a,b,c}")) + + assert response.status == 200 + assert await response.json() == {"data": {"a": "hey", "b": "hey2", "c": "hey3"}} + + +@pytest.mark.asyncio +async def test_preflight_request(client): + response = await client.options( + "/graphql", + headers={"Access-Control-Request-Method": "POST"}, + ) + + assert response.status == 200 + + +@pytest.mark.asyncio +async def test_preflight_incorrect_request(client): + response = await client.options( + "/graphql", + headers={"Access-Control-Request-Method": "OPTIONS"}, + ) + + assert response.status == 400 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "app", [create_app(execution_context_class=RepeatExecutionContext)] +) +async def test_custom_execution_context_class(client): + response = await client.post( + "/graphql", + data=json.dumps({"query": "{test}"}), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert await response.json() == {"data": {"test": "Hello WorldHello World"}} diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index ae78c3d..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,4 +0,0 @@ -import sys - -if sys.version_info[:2] < (3, 4): - collect_ignore_glob = ["*_asyncio.py"] diff --git a/tests/flask/__init__.py b/tests/flask/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/flask/app.py b/tests/flask/app.py new file mode 100644 index 0000000..ec9e9d0 --- /dev/null +++ b/tests/flask/app.py @@ -0,0 +1,18 @@ +from flask import Flask + +from graphql_server.flask import GraphQLView +from tests.flask.schema import Schema + + +def create_app(path="/graphql", **kwargs): + server = Flask(__name__) + server.debug = True + server.add_url_rule( + path, view_func=GraphQLView.as_view("graphql", schema=Schema, **kwargs) + ) + return server + + +if __name__ == "__main__": + app = create_app(graphiql=True) + app.run() diff --git a/tests/flask/conftest.py b/tests/flask/conftest.py new file mode 100644 index 0000000..5944b2e --- /dev/null +++ b/tests/flask/conftest.py @@ -0,0 +1,19 @@ +import pytest + +from .app import create_app + + +@pytest.fixture +def app(): + # import app factory pattern + app = create_app() + + # pushes an application context manually + ctx = app.app_context() + ctx.push() + return app + + +@pytest.fixture +def client(app): + return app.test_client() diff --git a/tests/flask/schema.py b/tests/flask/schema.py new file mode 100644 index 0000000..fc056fa --- /dev/null +++ b/tests/flask/schema.py @@ -0,0 +1,54 @@ +from graphql.type.definition import ( + GraphQLArgument, + GraphQLField, + GraphQLNonNull, + GraphQLObjectType, +) +from graphql.type.scalars import GraphQLString +from graphql.type.schema import GraphQLSchema + + +def resolve_raises(*_): + raise Exception("Throws!") + + +QueryRootType = GraphQLObjectType( + name="QueryRoot", + fields={ + "thrower": GraphQLField(GraphQLNonNull(GraphQLString), resolve=resolve_raises), + "request": GraphQLField( + GraphQLNonNull(GraphQLString), + resolve=lambda obj, info: info.context["request"].args.get("q"), + ), + "context": GraphQLField( + GraphQLObjectType( + name="context", + fields={ + "session": GraphQLField(GraphQLString), + "request": GraphQLField( + GraphQLNonNull(GraphQLString), + resolve=lambda obj, info: info.context["request"], + ), + "property": GraphQLField( + GraphQLString, resolve=lambda obj, info: info.context.property + ), + }, + ), + resolve=lambda obj, info: info.context, + ), + "test": GraphQLField( + type_=GraphQLString, + args={"who": GraphQLArgument(GraphQLString)}, + resolve=lambda obj, info, who="World": "Hello %s" % who, + ), + }, +) + +MutationRootType = GraphQLObjectType( + name="MutationRoot", + fields={ + "writeTest": GraphQLField(type_=QueryRootType, resolve=lambda *_: QueryRootType) + }, +) + +Schema = GraphQLSchema(QueryRootType, MutationRootType) diff --git a/tests/flask/test_graphiqlview.py b/tests/flask/test_graphiqlview.py new file mode 100644 index 0000000..bb398f6 --- /dev/null +++ b/tests/flask/test_graphiqlview.py @@ -0,0 +1,63 @@ +import pytest +from flask import url_for +from jinja2 import Environment + +from .app import create_app + + +@pytest.mark.parametrize( + "app", + [create_app(graphiql=True), create_app(graphiql=True, jinja_env=Environment())], +) +def test_graphiql_is_enabled(app, client): + with app.test_request_context(): + response = client.get( + url_for("graphql", externals=False), headers={"Accept": "text/html"} + ) + assert response.status_code == 200 + + +@pytest.mark.parametrize( + "app", + [create_app(graphiql=True), create_app(graphiql=True, jinja_env=Environment())], +) +def test_graphiql_renders_pretty(app, client): + with app.test_request_context(): + response = client.get( + url_for("graphql", query="{test}"), headers={"Accept": "text/html"} + ) + assert response.status_code == 200 + pretty_response = ( + "{\n" + ' "data": {\n' + ' "test": "Hello World"\n' + " }\n" + "}".replace('"', '\\"').replace("\n", "\\n") + ) # fmt: skip + + assert pretty_response in response.data.decode("utf-8") + + +@pytest.mark.parametrize( + "app", + [create_app(graphiql=True), create_app(graphiql=True, jinja_env=Environment())], +) +def test_graphiql_default_title(app, client): + with app.test_request_context(): + response = client.get(url_for("graphql"), headers={"Accept": "text/html"}) + assert "GraphiQL" in response.data.decode("utf-8") + + +@pytest.mark.parametrize( + "app", + [ + create_app(graphiql=True, graphiql_html_title="Awesome"), + create_app( + graphiql=True, graphiql_html_title="Awesome", jinja_env=Environment() + ), + ], +) +def test_graphiql_custom_title(app, client): + with app.test_request_context(): + response = client.get(url_for("graphql"), headers={"Accept": "text/html"}) + assert "Awesome" in response.data.decode("utf-8") diff --git a/tests/flask/test_graphqlview.py b/tests/flask/test_graphqlview.py new file mode 100644 index 0000000..3a6b87c --- /dev/null +++ b/tests/flask/test_graphqlview.py @@ -0,0 +1,606 @@ +import json +from urllib.parse import urlencode + +import pytest +from flask import url_for + +from ..utils import RepeatExecutionContext +from .app import create_app + + +def url_string(app, **url_params): + with app.test_request_context(): + url = url_for("graphql") + + return f"{url}?{urlencode(url_params)}" if url_params else url + + +def response_json(response): + return json.loads(response.data.decode()) + + +def json_dump_kwarg(**kwargs): + return json.dumps(kwargs) + + +def json_dump_kwarg_list(**kwargs): + return json.dumps([kwargs]) + + +def test_allows_get_with_query_param(app, client): + response = client.get(url_string(app, query="{test}")) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello World"}} + + +def test_allows_get_with_variable_values(app, client): + response = client.get( + url_string( + app, + query="query helloWho($who: String){ test(who: $who) }", + variables=json.dumps({"who": "Dolly"}), + ) + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_allows_get_with_operation_name(app, client): + response = client.get( + url_string( + app, + query=""" + query helloYou { test(who: "You"), ...shared } + query helloWorld { test(who: "World"), ...shared } + query helloDolly { test(who: "Dolly"), ...shared } + fragment shared on QueryRoot { + shared: test(who: "Everyone") + } + """, + operationName="helloWorld", + ) + ) + + assert response.status_code == 200 + assert response_json(response) == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +def test_reports_validation_errors(app, client): + response = client.get(url_string(app, query="{ test, unknownOne, unknownTwo }")) + + assert response.status_code == 400 + assert response_json(response) == { + "errors": [ + { + "message": "Cannot query field 'unknownOne' on type 'QueryRoot'.", + "locations": [{"line": 1, "column": 9}], + }, + { + "message": "Cannot query field 'unknownTwo' on type 'QueryRoot'.", + "locations": [{"line": 1, "column": 21}], + }, + ] + } + + +def test_errors_when_missing_operation_name(app, client): + response = client.get( + url_string( + app, + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + """, + ) + ) + + assert response.status_code == 400 + assert response_json(response) == { + "errors": [ + { + "message": "Must provide operation name" + " if query contains multiple operations.", + } + ] + } + + +def test_errors_when_sending_a_mutation_via_get(app, client): + response = client.get( + url_string( + app, + query=""" + mutation TestMutation { writeTest { test } } + """, + ) + ) + assert response.status_code == 405 + assert response_json(response) == { + "errors": [ + { + "message": "Can only perform a mutation operation from a POST request.", + } + ] + } + + +def test_errors_when_selecting_a_mutation_within_a_get(app, client): + response = client.get( + url_string( + app, + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + """, + operationName="TestMutation", + ) + ) + + assert response.status_code == 405 + assert response_json(response) == { + "errors": [ + { + "message": "Can only perform a mutation operation from a POST request.", + } + ] + } + + +def test_allows_mutation_to_exist_within_a_get(app, client): + response = client.get( + url_string( + app, + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + """, + operationName="TestQuery", + ) + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello World"}} + + +def test_allows_post_with_json_encoding(app, client): + response = client.post( + url_string(app), + data=json_dump_kwarg(query="{test}"), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello World"}} + + +def test_allows_sending_a_mutation_via_post(app, client): + response = client.post( + url_string(app), + data=json_dump_kwarg(query="mutation TestMutation { writeTest { test } }"), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"writeTest": {"test": "Hello World"}}} + + +def test_allows_post_with_url_encoding(app, client): + response = client.post( + url_string(app), + data=urlencode({"query": "{test}"}), + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello World"}} + + +def test_supports_post_json_query_with_string_variables(app, client): + response = client.post( + url_string(app), + data=json_dump_kwarg( + query="query helloWho($who: String){ test(who: $who) }", + variables=json.dumps({"who": "Dolly"}), + ), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_supports_post_json_query_with_json_variables(app, client): + response = client.post( + url_string(app), + data=json_dump_kwarg( + query="query helloWho($who: String){ test(who: $who) }", + variables={"who": "Dolly"}, + ), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_supports_post_url_encoded_query_with_string_variables(app, client): + response = client.post( + url_string(app), + data=urlencode( + { + "query": "query helloWho($who: String){ test(who: $who) }", + "variables": json.dumps({"who": "Dolly"}), + } + ), + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_supports_post_json_query_with_get_variable_values(app, client): + response = client.post( + url_string(app, variables=json.dumps({"who": "Dolly"})), + data=json_dump_kwarg( + query="query helloWho($who: String){ test(who: $who) }", + ), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_post_url_encoded_query_with_get_variable_values(app, client): + response = client.post( + url_string(app, variables=json.dumps({"who": "Dolly"})), + data=urlencode( + { + "query": "query helloWho($who: String){ test(who: $who) }", + } + ), + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_supports_post_raw_text_query_with_get_variable_values(app, client): + response = client.post( + url_string(app, variables=json.dumps({"who": "Dolly"})), + data="query helloWho($who: String){ test(who: $who) }", + content_type="application/graphql", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_allows_post_with_operation_name(app, client): + response = client.post( + url_string(app), + data=json_dump_kwarg( + query=""" + query helloYou { test(who: "You"), ...shared } + query helloWorld { test(who: "World"), ...shared } + query helloDolly { test(who: "Dolly"), ...shared } + fragment shared on QueryRoot { + shared: test(who: "Everyone") + } + """, + operationName="helloWorld", + ), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +def test_allows_post_with_get_operation_name(app, client): + response = client.post( + url_string(app, operationName="helloWorld"), + data=""" + query helloYou { test(who: "You"), ...shared } + query helloWorld { test(who: "World"), ...shared } + query helloDolly { test(who: "Dolly"), ...shared } + fragment shared on QueryRoot { + shared: test(who: "Everyone") + } + """, + content_type="application/graphql", + ) + + assert response.status_code == 200 + assert response_json(response) == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +@pytest.mark.parametrize("app", [create_app(pretty=True)]) +def test_supports_pretty_printing(app, client): + response = client.get(url_string(app, query="{test}")) + + assert response.data.decode() == ( + "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" + ) + + +@pytest.mark.parametrize("app", [create_app(pretty=False)]) +def test_not_pretty_by_default(app, client): + response = client.get(url_string(app, query="{test}")) + + assert response.data.decode() == '{"data":{"test":"Hello World"}}' + + +def test_supports_pretty_printing_by_request(app, client): + response = client.get(url_string(app, query="{test}", pretty="1")) + + assert response.data.decode() == ( + "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" + ) + + +def test_handles_field_errors_caught_by_graphql(app, client): + response = client.get(url_string(app, query="{thrower}")) + assert response.status_code == 200 + assert response_json(response) == { + "errors": [ + { + "locations": [{"column": 2, "line": 1}], + "path": ["thrower"], + "message": "Throws!", + } + ], + "data": None, + } + + +def test_handles_syntax_errors_caught_by_graphql(app, client): + response = client.get(url_string(app, query="syntaxerror")) + assert response.status_code == 400 + assert response_json(response) == { + "errors": [ + { + "locations": [{"column": 1, "line": 1}], + "message": "Syntax Error: Unexpected Name 'syntaxerror'.", + } + ] + } + + +def test_handles_errors_caused_by_a_lack_of_query(app, client): + response = client.get(url_string(app)) + + assert response.status_code == 400 + assert response_json(response) == { + "errors": [ + { + "message": "Must provide query string.", + } + ] + } + + +def test_handles_batch_correctly_if_is_disabled(app, client): + response = client.post(url_string(app), data="[]", content_type="application/json") + + assert response.status_code == 400 + assert response_json(response) == { + "errors": [ + { + "message": "Batch GraphQL requests are not enabled.", + } + ] + } + + +def test_handles_incomplete_json_bodies(app, client): + response = client.post( + url_string(app), data='{"query":', content_type="application/json" + ) + + assert response.status_code == 400 + assert response_json(response) == { + "errors": [ + { + "message": "POST body sent invalid JSON.", + } + ] + } + + +def test_handles_plain_post_text(app, client): + response = client.post( + url_string(app, variables=json.dumps({"who": "Dolly"})), + data="query helloWho($who: String){ test(who: $who) }", + content_type="text/plain", + ) + assert response.status_code == 400 + assert response_json(response) == { + "errors": [ + { + "message": "Must provide query string.", + } + ] + } + + +def test_handles_poorly_formed_variables(app, client): + response = client.get( + url_string( + app, + query="query helloWho($who: String){ test(who: $who) }", + variables="who:You", + ) + ) + assert response.status_code == 400 + assert response_json(response) == { + "errors": [ + { + "message": "Variables are invalid JSON.", + } + ] + } + + +def test_handles_unsupported_http_methods(app, client): + response = client.put(url_string(app, query="{test}")) + assert response.status_code == 405 + assert response.headers["Allow"] in ["GET, POST", "HEAD, GET, POST, OPTIONS"] + assert response_json(response) == { + "errors": [ + { + "message": "GraphQL only supports GET and POST requests.", + } + ] + } + + +def test_passes_request_into_request_context(app, client): + response = client.get(url_string(app, query="{request}", q="testing")) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"request": "testing"}} + + +@pytest.mark.parametrize("app", [create_app(context={"session": "CUSTOM CONTEXT"})]) +def test_passes_custom_context_into_context(app, client): + response = client.get(url_string(app, query="{context { session request }}")) + + assert response.status_code == 200 + res = response_json(response) + assert "data" in res + assert "session" in res["data"]["context"] + assert "request" in res["data"]["context"] + assert "CUSTOM CONTEXT" in res["data"]["context"]["session"] + assert "Request" in res["data"]["context"]["request"] + + +@pytest.mark.parametrize("app", [create_app(context="CUSTOM CONTEXT")]) +def test_context_remapped_if_not_mapping(app, client): + response = client.get(url_string(app, query="{context { session request }}")) + + assert response.status_code == 200 + res = response_json(response) + assert "data" in res + assert "session" in res["data"]["context"] + assert "request" in res["data"]["context"] + assert "CUSTOM CONTEXT" not in res["data"]["context"]["request"] + assert "Request" in res["data"]["context"]["request"] + + +class CustomContext(dict): + property = "A custom property" + + +@pytest.mark.parametrize("app", [create_app(context=CustomContext())]) +def test_allow_empty_custom_context(app, client): + response = client.get(url_string(app, query="{context { property request }}")) + + assert response.status_code == 200 + res = response_json(response) + assert "data" in res + assert "request" in res["data"]["context"] + assert "property" in res["data"]["context"] + assert "A custom property" == res["data"]["context"]["property"] + assert "Request" in res["data"]["context"]["request"] + + +def test_post_multipart_data(app, client): + query = "mutation TestMutation { writeTest { test } }" + + data = ( + "------flaskgraphql\r\n" + 'Content-Disposition: form-data; name="query"\r\n' + "\r\n" + query + "\r\n" + "------flaskgraphql--\r\n" + "Content-Type: text/plain; charset=utf-8\r\n" + 'Content-Disposition: form-data; name="file"; filename="text1.txt";' + " filename*=utf-8''text1.txt\r\n" + "\r\n" + "\r\n" + "------flaskgraphql--\r\n" + ) + + response = client.post( + url_string(app), + data=data, + content_type="multipart/form-data; boundary=----flaskgraphql", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"writeTest": {"test": "Hello World"}}} + + +@pytest.mark.parametrize("app", [create_app(batch=True)]) +def test_batch_allows_post_with_json_encoding(app, client): + response = client.post( + url_string(app), + data=json_dump_kwarg_list(query="{test}"), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == [{"data": {"test": "Hello World"}}] + + +@pytest.mark.parametrize("app", [create_app(batch=True)]) +def test_batch_supports_post_json_query_with_json_variables(app, client): + response = client.post( + url_string(app), + data=json_dump_kwarg_list( + query="query helloWho($who: String){ test(who: $who) }", + variables={"who": "Dolly"}, + ), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == [{"data": {"test": "Hello Dolly"}}] + + +@pytest.mark.parametrize("app", [create_app(batch=True)]) +def test_batch_allows_post_with_operation_name(app, client): + response = client.post( + url_string(app), + data=json_dump_kwarg_list( + query=""" + query helloYou { test(who: "You"), ...shared } + query helloWorld { test(who: "World"), ...shared } + query helloDolly { test(who: "Dolly"), ...shared } + fragment shared on QueryRoot { + shared: test(who: "Everyone") + } + """, + operationName="helloWorld", + ), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == [ + {"data": {"test": "Hello World", "shared": "Hello Everyone"}} + ] + + +@pytest.mark.parametrize( + "app", [create_app(execution_context_class=RepeatExecutionContext)] +) +def test_custom_execution_context_class(app, client): + response = client.post( + url_string(app), + data=json_dump_kwarg(query="{test}"), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello WorldHello World"}} diff --git a/tests/quart/__init__.py b/tests/quart/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/quart/app.py b/tests/quart/app.py new file mode 100644 index 0000000..adfce41 --- /dev/null +++ b/tests/quart/app.py @@ -0,0 +1,18 @@ +from quart import Quart + +from graphql_server.quart import GraphQLView +from tests.quart.schema import Schema + + +def create_app(path="/graphql", schema=Schema, **kwargs): + server = Quart(__name__) + server.debug = True + server.add_url_rule( + path, view_func=GraphQLView.as_view("graphql", schema=schema, **kwargs) + ) + return server + + +if __name__ == "__main__": + app = create_app(graphiql=True) + app.run() diff --git a/tests/quart/conftest.py b/tests/quart/conftest.py new file mode 100644 index 0000000..bb5a38c --- /dev/null +++ b/tests/quart/conftest.py @@ -0,0 +1,23 @@ +import pytest +from quart import Quart +from quart.typing import TestClientProtocol + +from .app import create_app + +TestClientProtocol.__test__ = False # type: ignore + + +@pytest.fixture +def app() -> Quart: + # import app factory pattern + app = create_app() + + # pushes an application context manually + # ctx = app.app_context() + # await ctx.push() + return app + + +@pytest.fixture +def client(app: Quart) -> TestClientProtocol: + return app.test_client() diff --git a/tests/quart/schema.py b/tests/quart/schema.py new file mode 100644 index 0000000..d3804ea --- /dev/null +++ b/tests/quart/schema.py @@ -0,0 +1,102 @@ +import asyncio + +from graphql.type.definition import ( + GraphQLArgument, + GraphQLField, + GraphQLNonNull, + GraphQLObjectType, +) +from graphql.type.scalars import GraphQLString +from graphql.type.schema import GraphQLSchema + + +def resolve_raises(*_): + raise Exception("Throws!") + + +# Sync schema +QueryRootType = GraphQLObjectType( + name="QueryRoot", + fields={ + "thrower": GraphQLField(GraphQLNonNull(GraphQLString), resolve=resolve_raises), + "request": GraphQLField( + GraphQLNonNull(GraphQLString), + resolve=lambda obj, info: info.context["request"].args.get("q"), + ), + "context": GraphQLField( + GraphQLObjectType( + name="context", + fields={ + "session": GraphQLField(GraphQLString), + "request": GraphQLField( + GraphQLNonNull(GraphQLString), + resolve=lambda obj, info: info.context["request"], + ), + "property": GraphQLField( + GraphQLString, resolve=lambda obj, info: info.context.property + ), + }, + ), + resolve=lambda obj, info: info.context, + ), + "test": GraphQLField( + type_=GraphQLString, + args={"who": GraphQLArgument(GraphQLString)}, + resolve=lambda obj, info, who="World": f"Hello {who}", + ), + }, +) + +MutationRootType = GraphQLObjectType( + name="MutationRoot", + fields={ + "writeTest": GraphQLField(type_=QueryRootType, resolve=lambda *_: QueryRootType) + }, +) + +Schema = GraphQLSchema(QueryRootType, MutationRootType) + + +# Schema with async methods +async def resolver_field_async_1(_obj, info): + await asyncio.sleep(0.001) + return "hey" + + +async def resolver_field_async_2(_obj, info): + await asyncio.sleep(0.003) + return "hey2" + + +def resolver_field_sync(_obj, info): + return "hey3" + + +AsyncQueryType = GraphQLObjectType( + name="AsyncQueryType", + fields={ + "a": GraphQLField(GraphQLString, resolve=resolver_field_async_1), + "b": GraphQLField(GraphQLString, resolve=resolver_field_async_2), + "c": GraphQLField(GraphQLString, resolve=resolver_field_sync), + }, +) + + +def resolver_field_sync_1(_obj, info): + return "synced_one" + + +def resolver_field_sync_2(_obj, info): + return "synced_two" + + +SyncQueryType = GraphQLObjectType( + "SyncQueryType", + { + "a": GraphQLField(GraphQLString, resolve=resolver_field_sync_1), + "b": GraphQLField(GraphQLString, resolve=resolver_field_sync_2), + }, +) + +AsyncSchema = GraphQLSchema(AsyncQueryType) +SyncSchema = GraphQLSchema(SyncQueryType) diff --git a/tests/quart/test_graphiqlview.py b/tests/quart/test_graphiqlview.py new file mode 100644 index 0000000..4b78ec2 --- /dev/null +++ b/tests/quart/test_graphiqlview.py @@ -0,0 +1,87 @@ +from typing import Optional + +import pytest +from jinja2 import Environment +from quart import Quart, Response, url_for +from quart.typing import TestClientProtocol +from werkzeug.datastructures import Headers + +from .app import create_app + + +@pytest.mark.asyncio +async def execute_client( + app: Quart, + client: TestClientProtocol, + method: str = "GET", + headers: Optional[Headers] = None, + **extra_params, +) -> Response: + test_request_context = app.test_request_context(path="/", method=method) + async with test_request_context: + string = url_for("graphql", **extra_params) + return await client.get(string, headers=headers) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "app", + [create_app(graphiql=True), create_app(graphiql=True, jinja_env=Environment())], +) +async def test_graphiql_is_enabled(app: Quart, client: TestClientProtocol): + response = await execute_client( + app, client, headers=Headers({"Accept": "text/html"}), externals=False + ) + assert response.status_code == 200 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "app", + [create_app(graphiql=True), create_app(graphiql=True, jinja_env=Environment())], +) +async def test_graphiql_renders_pretty(app: Quart, client: TestClientProtocol): + response = await execute_client( + app, client, headers=Headers({"Accept": "text/html"}), query="{test}" + ) + assert response.status_code == 200 + pretty_response = ( + "{\n" + ' "data": {\n' + ' "test": "Hello World"\n' + " }\n" + "}".replace('"', '\\"').replace("\n", "\\n") + ) # fmt: skip + result = await response.get_data(as_text=True) + assert pretty_response in result + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "app", + [create_app(graphiql=True), create_app(graphiql=True, jinja_env=Environment())], +) +async def test_graphiql_default_title(app: Quart, client: TestClientProtocol): + response = await execute_client( + app, client, headers=Headers({"Accept": "text/html"}) + ) + result = await response.get_data(as_text=True) + assert "GraphiQL" in result + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "app", + [ + create_app(graphiql=True, graphiql_html_title="Awesome"), + create_app( + graphiql=True, graphiql_html_title="Awesome", jinja_env=Environment() + ), + ], +) +async def test_graphiql_custom_title(app: Quart, client: TestClientProtocol): + response = await execute_client( + app, client, headers=Headers({"Accept": "text/html"}) + ) + result = await response.get_data(as_text=True) + assert "Awesome" in result diff --git a/tests/quart/test_graphqlview.py b/tests/quart/test_graphqlview.py new file mode 100644 index 0000000..17d12cc --- /dev/null +++ b/tests/quart/test_graphqlview.py @@ -0,0 +1,772 @@ +import json +from typing import Optional +from urllib.parse import urlencode + +import pytest +from quart import Quart, Response, url_for +from quart.typing import TestClientProtocol +from werkzeug.datastructures import Headers + +from ..utils import RepeatExecutionContext +from .app import create_app +from .schema import AsyncSchema + + +@pytest.mark.asyncio +async def execute_client( + app: Quart, + client: TestClientProtocol, + method: str = "GET", + data: Optional[str] = None, + headers: Optional[Headers] = None, + **url_params, +) -> Response: + test_request_context = app.test_request_context(path="/", method=method) + async with test_request_context: + string = url_for("graphql") + + if url_params: + string += "?" + urlencode(url_params) + + if method == "POST": + return await client.post(string, data=data, headers=headers) + elif method == "PUT": + return await client.put(string, data=data, headers=headers) + else: + return await client.get(string) + + +def response_json(result): + return json.loads(result) + + +def json_dump_kwarg(**kwargs) -> str: + return json.dumps(kwargs) + + +def json_dump_kwarg_list(**kwargs): + return json.dumps([kwargs]) + + +@pytest.mark.asyncio +async def test_allows_get_with_query_param(app: Quart, client: TestClientProtocol): + response = await execute_client(app, client, query="{test}") + + assert response.status_code == 200 + result = await response.get_data(as_text=True) + assert response_json(result) == {"data": {"test": "Hello World"}} + + +@pytest.mark.asyncio +async def test_allows_get_with_variable_values(app: Quart, client: TestClientProtocol): + response = await execute_client( + app, + client, + query="query helloWho($who: String){ test(who: $who) }", + variables=json.dumps({"who": "Dolly"}), + ) + + assert response.status_code == 200 + result = await response.get_data(as_text=True) + assert response_json(result) == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_allows_get_with_operation_name(app: Quart, client: TestClientProtocol): + response = await execute_client( + app, + client, + query=""" + query helloYou { test(who: "You"), ...shared } + query helloWorld { test(who: "World"), ...shared } + query helloDolly { test(who: "Dolly"), ...shared } + fragment shared on QueryRoot { + shared: test(who: "Everyone") + } + """, + operationName="helloWorld", + ) + + assert response.status_code == 200 + result = await response.get_data(as_text=True) + assert response_json(result) == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +@pytest.mark.asyncio +async def test_reports_validation_errors(app: Quart, client: TestClientProtocol): + response = await execute_client( + app, client, query="{ test, unknownOne, unknownTwo }" + ) + + assert response.status_code == 400 + result = await response.get_data(as_text=True) + assert response_json(result) == { + "errors": [ + { + "message": "Cannot query field 'unknownOne' on type 'QueryRoot'.", + "locations": [{"line": 1, "column": 9}], + }, + { + "message": "Cannot query field 'unknownTwo' on type 'QueryRoot'.", + "locations": [{"line": 1, "column": 21}], + }, + ] + } + + +@pytest.mark.asyncio +async def test_errors_when_missing_operation_name( + app: Quart, client: TestClientProtocol +): + response = await execute_client( + app, + client, + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + """, + ) + + assert response.status_code == 400 + result = await response.get_data(as_text=True) + assert response_json(result) == { + "errors": [ + { + "message": "Must provide operation name" + " if query contains multiple operations.", + } + ] + } + + +@pytest.mark.asyncio +async def test_errors_when_sending_a_mutation_via_get( + app: Quart, client: TestClientProtocol +): + response = await execute_client( + app, + client, + query=""" + mutation TestMutation { writeTest { test } } + """, + ) + assert response.status_code == 405 + result = await response.get_data(as_text=True) + assert response_json(result) == { + "errors": [ + { + "message": "Can only perform a mutation operation from a POST request.", + } + ] + } + + +@pytest.mark.asyncio +async def test_errors_when_selecting_a_mutation_within_a_get( + app: Quart, client: TestClientProtocol +): + response = await execute_client( + app, + client, + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + """, + operationName="TestMutation", + ) + + assert response.status_code == 405 + result = await response.get_data(as_text=True) + assert response_json(result) == { + "errors": [ + { + "message": "Can only perform a mutation operation from a POST request.", + } + ] + } + + +@pytest.mark.asyncio +async def test_allows_mutation_to_exist_within_a_get( + app: Quart, client: TestClientProtocol +): + response = await execute_client( + app, + client, + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + """, + operationName="TestQuery", + ) + + assert response.status_code == 200 + result = await response.get_data(as_text=True) + assert response_json(result) == {"data": {"test": "Hello World"}} + + +@pytest.mark.asyncio +async def test_allows_post_with_json_encoding(app: Quart, client: TestClientProtocol): + response = await execute_client( + app, + client, + method="POST", + data=json_dump_kwarg(query="{test}"), + headers=Headers({"Content-Type": "application/json"}), + ) + + assert response.status_code == 200 + result = await response.get_data(as_text=True) + assert response_json(result) == {"data": {"test": "Hello World"}} + + +@pytest.mark.asyncio +async def test_allows_sending_a_mutation_via_post( + app: Quart, client: TestClientProtocol +): + response = await execute_client( + app, + client, + method="POST", + data=json_dump_kwarg(query="mutation TestMutation { writeTest { test } }"), + headers=Headers({"Content-Type": "application/json"}), + ) + + assert response.status_code == 200 + result = await response.get_data(as_text=True) + assert response_json(result) == {"data": {"writeTest": {"test": "Hello World"}}} + + +@pytest.mark.asyncio +async def test_allows_post_with_url_encoding(app: Quart, client: TestClientProtocol): + response = await execute_client( + app, + client, + method="POST", + data=urlencode({"query": "{test}"}), + headers=Headers({"Content-Type": "application/x-www-form-urlencoded"}), + ) + + assert response.status_code == 200 + result = await response.get_data(as_text=True) + assert response_json(result) == {"data": {"test": "Hello World"}} + + +@pytest.mark.asyncio +async def test_supports_post_json_query_with_string_variables( + app: Quart, client: TestClientProtocol +): + response = await execute_client( + app, + client, + method="POST", + data=json_dump_kwarg( + query="query helloWho($who: String){ test(who: $who) }", + variables=json.dumps({"who": "Dolly"}), + ), + headers=Headers({"Content-Type": "application/json"}), + ) + + assert response.status_code == 200 + result = await response.get_data(as_text=True) + assert response_json(result) == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_supports_post_json_query_with_json_variables( + app: Quart, client: TestClientProtocol +): + response = await execute_client( + app, + client, + method="POST", + data=json_dump_kwarg( + query="query helloWho($who: String){ test(who: $who) }", + variables={"who": "Dolly"}, + ), + headers=Headers({"Content-Type": "application/json"}), + ) + + assert response.status_code == 200 + result = await response.get_data(as_text=True) + assert response_json(result) == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_supports_post_url_encoded_query_with_string_variables( + app: Quart, client: TestClientProtocol +): + response = await execute_client( + app, + client, + method="POST", + data=urlencode( + { + "query": "query helloWho($who: String){ test(who: $who) }", + "variables": json.dumps({"who": "Dolly"}), + } + ), + headers=Headers({"Content-Type": "application/x-www-form-urlencoded"}), + ) + + assert response.status_code == 200 + result = await response.get_data(as_text=True) + assert response_json(result) == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_supports_post_json_query_with_get_variable_values( + app: Quart, client: TestClientProtocol +): + response = await execute_client( + app, + client, + method="POST", + data=json_dump_kwarg( + query="query helloWho($who: String){ test(who: $who) }", + ), + headers=Headers({"Content-Type": "application/json"}), + variables=json.dumps({"who": "Dolly"}), + ) + + assert response.status_code == 200 + result = await response.get_data(as_text=True) + assert response_json(result) == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_post_url_encoded_query_with_get_variable_values( + app: Quart, client: TestClientProtocol +): + response = await execute_client( + app, + client, + method="POST", + data=urlencode( + { + "query": "query helloWho($who: String){ test(who: $who) }", + } + ), + headers=Headers({"Content-Type": "application/x-www-form-urlencoded"}), + variables=json.dumps({"who": "Dolly"}), + ) + + assert response.status_code == 200 + result = await response.get_data(as_text=True) + assert response_json(result) == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_supports_post_raw_text_query_with_get_variable_values( + app: Quart, client: TestClientProtocol +): + response = await execute_client( + app, + client=client, + method="POST", + data="query helloWho($who: String){ test(who: $who) }", + headers=Headers({"Content-Type": "application/graphql"}), + variables=json.dumps({"who": "Dolly"}), + ) + + assert response.status_code == 200 + result = await response.get_data(as_text=True) + assert response_json(result) == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_allows_post_with_operation_name(app: Quart, client: TestClientProtocol): + response = await execute_client( + app, + client, + method="POST", + data=json_dump_kwarg( + query=""" + query helloYou { test(who: "You"), ...shared } + query helloWorld { test(who: "World"), ...shared } + query helloDolly { test(who: "Dolly"), ...shared } + fragment shared on QueryRoot { + shared: test(who: "Everyone") + } + """, + operationName="helloWorld", + ), + headers=Headers({"Content-Type": "application/json"}), + ) + + assert response.status_code == 200 + result = await response.get_data(as_text=True) + assert response_json(result) == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +@pytest.mark.asyncio +async def test_allows_post_with_get_operation_name( + app: Quart, client: TestClientProtocol +): + response = await execute_client( + app, + client, + method="POST", + data=""" + query helloYou { test(who: "You"), ...shared } + query helloWorld { test(who: "World"), ...shared } + query helloDolly { test(who: "Dolly"), ...shared } + fragment shared on QueryRoot { + shared: test(who: "Everyone") + } + """, + headers=Headers({"Content-Type": "application/graphql"}), + operationName="helloWorld", + ) + + assert response.status_code == 200 + result = await response.get_data(as_text=True) + assert response_json(result) == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(pretty=True)]) +async def test_supports_pretty_printing(app: Quart, client: TestClientProtocol): + response = await execute_client(app, client, query="{test}") + + result = await response.get_data(as_text=True) + assert result == ("{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(pretty=False)]) +async def test_not_pretty_by_default(app: Quart, client: TestClientProtocol): + response = await execute_client(app, client, query="{test}") + + result = await response.get_data(as_text=True) + assert result == '{"data":{"test":"Hello World"}}' + + +@pytest.mark.asyncio +async def test_supports_pretty_printing_by_request( + app: Quart, client: TestClientProtocol +): + response = await execute_client(app, client, query="{test}", pretty="1") + + result = await response.get_data(as_text=True) + assert result == "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" + + +@pytest.mark.asyncio +async def test_handles_field_errors_caught_by_graphql( + app: Quart, client: TestClientProtocol +): + response = await execute_client(app, client, query="{thrower}") + assert response.status_code == 200 + result = await response.get_data(as_text=True) + assert response_json(result) == { + "errors": [ + { + "locations": [{"column": 2, "line": 1}], + "path": ["thrower"], + "message": "Throws!", + } + ], + "data": None, + } + + +@pytest.mark.asyncio +async def test_handles_syntax_errors_caught_by_graphql( + app: Quart, client: TestClientProtocol +): + response = await execute_client(app, client, query="syntaxerror") + assert response.status_code == 400 + result = await response.get_data(as_text=True) + assert response_json(result) == { + "errors": [ + { + "locations": [{"column": 1, "line": 1}], + "message": "Syntax Error: Unexpected Name 'syntaxerror'.", + } + ] + } + + +@pytest.mark.asyncio +async def test_handles_errors_caused_by_a_lack_of_query( + app: Quart, client: TestClientProtocol +): + response = await execute_client(app, client) + + assert response.status_code == 400 + result = await response.get_data(as_text=True) + assert response_json(result) == { + "errors": [{"message": "Must provide query string."}] + } + + +@pytest.mark.asyncio +async def test_handles_batch_correctly_if_is_disabled( + app: Quart, client: TestClientProtocol +): + response = await execute_client( + app, + client, + method="POST", + data="[]", + headers=Headers({"Content-Type": "application/json"}), + ) + + assert response.status_code == 400 + result = await response.get_data(as_text=True) + assert response_json(result) == { + "errors": [ + { + "message": "Batch GraphQL requests are not enabled.", + } + ] + } + + +@pytest.mark.asyncio +async def test_handles_incomplete_json_bodies(app: Quart, client: TestClientProtocol): + response = await execute_client( + app, + client, + method="POST", + data='{"query":', + headers=Headers({"Content-Type": "application/json"}), + ) + + assert response.status_code == 400 + result = await response.get_data(as_text=True) + assert response_json(result) == { + "errors": [{"message": "POST body sent invalid JSON."}] + } + + +@pytest.mark.asyncio +async def test_handles_plain_post_text(app: Quart, client: TestClientProtocol): + response = await execute_client( + app, + client, + method="POST", + data="query helloWho($who: String){ test(who: $who) }", + headers=Headers({"Content-Type": "text/plain"}), + variables=json.dumps({"who": "Dolly"}), + ) + assert response.status_code == 400 + result = await response.get_data(as_text=True) + assert response_json(result) == { + "errors": [{"message": "Must provide query string."}] + } + + +@pytest.mark.asyncio +async def test_handles_poorly_formed_variables(app: Quart, client: TestClientProtocol): + response = await execute_client( + app, + client, + query="query helloWho($who: String){ test(who: $who) }", + variables="who:You", + ) + assert response.status_code == 400 + result = await response.get_data(as_text=True) + assert response_json(result) == { + "errors": [{"message": "Variables are invalid JSON."}] + } + + +@pytest.mark.asyncio +async def test_handles_unsupported_http_methods(app: Quart, client: TestClientProtocol): + response = await execute_client(app, client, method="PUT", query="{test}") + assert response.status_code == 405 + result = await response.get_data(as_text=True) + assert response.headers["Allow"] in ["GET, POST", "HEAD, GET, POST, OPTIONS"] + assert response_json(result) == { + "errors": [{"message": "GraphQL only supports GET and POST requests."}] + } + + +@pytest.mark.asyncio +async def test_passes_request_into_request_context( + app: Quart, client: TestClientProtocol +): + response = await execute_client(app, client, query="{request}", q="testing") + + assert response.status_code == 200 + result = await response.get_data(as_text=True) + assert response_json(result) == {"data": {"request": "testing"}} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(context={"session": "CUSTOM CONTEXT"})]) +async def test_passes_custom_context_into_context( + app: Quart, client: TestClientProtocol +): + response = await execute_client(app, client, query="{context { session request }}") + + assert response.status_code == 200 + result = await response.get_data(as_text=True) + res = response_json(result) + assert "data" in res + assert "session" in res["data"]["context"] + assert "request" in res["data"]["context"] + assert "CUSTOM CONTEXT" in res["data"]["context"]["session"] + assert "Request" in res["data"]["context"]["request"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(context="CUSTOM CONTEXT")]) +async def test_context_remapped_if_not_mapping(app: Quart, client: TestClientProtocol): + response = await execute_client(app, client, query="{context { session request }}") + + assert response.status_code == 200 + result = await response.get_data(as_text=True) + res = response_json(result) + assert "data" in res + assert "session" in res["data"]["context"] + assert "request" in res["data"]["context"] + assert "CUSTOM CONTEXT" not in res["data"]["context"]["request"] + assert "Request" in res["data"]["context"]["request"] + + +class CustomContext(dict): + property = "A custom property" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(context=CustomContext())]) +async def test_allow_empty_custom_context(app: Quart, client: TestClientProtocol): + response = await execute_client(app, client, query="{context { property request }}") + + assert response.status_code == 200 + result = await response.get_data(as_text=True) + res = response_json(result) + assert "data" in res + assert "request" in res["data"]["context"] + assert "property" in res["data"]["context"] + assert "A custom property" == res["data"]["context"]["property"] + assert "Request" in res["data"]["context"]["request"] + + +# @pytest.mark.asyncio +# async def test_post_multipart_data(app: Quart, client: TestClientProtocol): +# query = "mutation TestMutation { writeTest { test } }" +# response = await execute_client( +# app, +# client, +# method='POST', +# data={"query": query, "file": (StringIO(), "text1.txt")}, +# headers=Headers({"Content-Type": "multipart/form-data"}) +# ) +# +# assert response.status_code == 200 +# result = await response.get_data() +# assert response_json(result) == { +# "data": {u"writeTest": {u"test": u"Hello World"}} +# } + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(batch=True)]) +async def test_batch_allows_post_with_json_encoding( + app: Quart, client: TestClientProtocol +): + response = await execute_client( + app, + client, + method="POST", + data=json_dump_kwarg_list(query="{test}"), + headers=Headers({"Content-Type": "application/json"}), + ) + + assert response.status_code == 200 + result = await response.get_data(as_text=True) + assert response_json(result) == [{"data": {"test": "Hello World"}}] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(batch=True)]) +async def test_batch_supports_post_json_query_with_json_variables( + app: Quart, client: TestClientProtocol +): + response = await execute_client( + app, + client, + method="POST", + data=json_dump_kwarg_list( + query="query helloWho($who: String){ test(who: $who) }", + variables={"who": "Dolly"}, + ), + headers=Headers({"Content-Type": "application/json"}), + ) + + assert response.status_code == 200 + result = await response.get_data(as_text=True) + assert response_json(result) == [{"data": {"test": "Hello Dolly"}}] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(batch=True)]) +async def test_batch_allows_post_with_operation_name( + app: Quart, client: TestClientProtocol +): + response = await execute_client( + app, + client, + method="POST", + data=json_dump_kwarg_list( + # id=1, + query=""" + query helloYou { test(who: "You"), ...shared } + query helloWorld { test(who: "World"), ...shared } + query helloDolly { test(who: "Dolly"), ...shared } + fragment shared on QueryRoot { + shared: test(who: "Everyone") + } + """, + operationName="helloWorld", + ), + headers=Headers({"Content-Type": "application/json"}), + ) + + assert response.status_code == 200 + result = await response.get_data(as_text=True) + assert response_json(result) == [ + {"data": {"test": "Hello World", "shared": "Hello Everyone"}} + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(schema=AsyncSchema, enable_async=True)]) +async def test_async_schema(app, client): + response = await execute_client( + app, + client, + query="{a,b,c}", + ) + + assert response.status_code == 200 + result = await response.get_data(as_text=True) + assert response_json(result) == {"data": {"a": "hey", "b": "hey2", "c": "hey3"}} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "app", [create_app(execution_context_class=RepeatExecutionContext)] +) +async def test_custom_execution_context_class(app: Quart, client: TestClientProtocol): + response = await execute_client( + app, + client, + method="POST", + data=json_dump_kwarg(query="{test}"), + headers=Headers({"Content-Type": "application/json"}), + ) + + assert response.status_code == 200 + result = await response.get_data(as_text=True) + assert response_json(result) == {"data": {"test": "Hello WorldHello World"}} diff --git a/tests/sanic/__init__.py b/tests/sanic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/sanic/app.py b/tests/sanic/app.py new file mode 100644 index 0000000..2cabd73 --- /dev/null +++ b/tests/sanic/app.py @@ -0,0 +1,21 @@ +import uuid +from urllib.parse import urlencode + +from sanic import Sanic + +from graphql_server.sanic import GraphQLView + +from .schema import Schema + + +def create_app(path="/graphql", schema=Schema, **kwargs): + random_valid_app_name = f"App{uuid.uuid4().hex}" + app = Sanic(random_valid_app_name) + + app.add_route(GraphQLView.as_view(schema=schema, **kwargs), path) + + return app + + +def url_string(url="/graphql", **url_params): + return f"{url}?{urlencode(url_params)}" if url_params else url diff --git a/tests/sanic/conftest.py b/tests/sanic/conftest.py new file mode 100644 index 0000000..2ba7d3c --- /dev/null +++ b/tests/sanic/conftest.py @@ -0,0 +1,8 @@ +import pytest + +from .app import create_app + + +@pytest.fixture +def app(): + return create_app() diff --git a/tests/sanic/schema.py b/tests/sanic/schema.py new file mode 100644 index 0000000..5df9a20 --- /dev/null +++ b/tests/sanic/schema.py @@ -0,0 +1,102 @@ +import asyncio + +from graphql.type.definition import ( + GraphQLArgument, + GraphQLField, + GraphQLNonNull, + GraphQLObjectType, +) +from graphql.type.scalars import GraphQLString +from graphql.type.schema import GraphQLSchema + + +def resolve_raises(*_): + raise Exception("Throws!") + + +# Sync schema +QueryRootType = GraphQLObjectType( + name="QueryRoot", + fields={ + "thrower": GraphQLField(GraphQLNonNull(GraphQLString), resolve=resolve_raises), + "request": GraphQLField( + GraphQLNonNull(GraphQLString), + resolve=lambda obj, info: info.context["request"].args.get("q"), + ), + "context": GraphQLField( + GraphQLObjectType( + name="context", + fields={ + "session": GraphQLField(GraphQLString), + "request": GraphQLField( + GraphQLNonNull(GraphQLString), + resolve=lambda obj, info: info.context["request"], + ), + "property": GraphQLField( + GraphQLString, resolve=lambda obj, info: info.context.property + ), + }, + ), + resolve=lambda obj, info: info.context, + ), + "test": GraphQLField( + type_=GraphQLString, + args={"who": GraphQLArgument(GraphQLString)}, + resolve=lambda obj, info, who=None: "Hello %s" % (who or "World"), + ), + }, +) + +MutationRootType = GraphQLObjectType( + name="MutationRoot", + fields={ + "writeTest": GraphQLField(type_=QueryRootType, resolve=lambda *_: QueryRootType) + }, +) + +Schema = GraphQLSchema(QueryRootType, MutationRootType) + + +# Schema with async methods +async def resolver_field_async_1(_obj, info): + await asyncio.sleep(0.001) + return "hey" + + +async def resolver_field_async_2(_obj, info): + await asyncio.sleep(0.003) + return "hey2" + + +def resolver_field_sync(_obj, info): + return "hey3" + + +AsyncQueryType = GraphQLObjectType( + name="AsyncQueryType", + fields={ + "a": GraphQLField(GraphQLString, resolve=resolver_field_async_1), + "b": GraphQLField(GraphQLString, resolve=resolver_field_async_2), + "c": GraphQLField(GraphQLString, resolve=resolver_field_sync), + }, +) + + +def resolver_field_sync_1(_obj, info): + return "synced_one" + + +def resolver_field_sync_2(_obj, info): + return "synced_two" + + +SyncQueryType = GraphQLObjectType( + "SyncQueryType", + { + "a": GraphQLField(GraphQLString, resolve=resolver_field_sync_1), + "b": GraphQLField(GraphQLString, resolve=resolver_field_sync_2), + }, +) + +AsyncSchema = GraphQLSchema(AsyncQueryType) +SyncSchema = GraphQLSchema(SyncQueryType) diff --git a/tests/sanic/test_graphiqlview.py b/tests/sanic/test_graphiqlview.py new file mode 100644 index 0000000..b5c877b --- /dev/null +++ b/tests/sanic/test_graphiqlview.py @@ -0,0 +1,91 @@ +import pytest +from jinja2 import Environment + +from .app import create_app, url_string +from .schema import AsyncSchema, SyncSchema + + +@pytest.mark.parametrize( + "app", + [ + create_app(graphiql=True), + create_app(graphiql=True, jinja_env=Environment()), + create_app(graphiql=True, jinja_env=Environment(enable_async=True)), + ], +) +def test_graphiql_is_enabled(app): + _, response = app.test_client.get( + uri=url_string(query="{test}"), headers={"Accept": "text/html"} + ) + + assert response.status == 200 + + pretty_response = ( + "{\n" + ' "data": {\n' + ' "test": "Hello World"\n' + " }\n" + "}".replace('"', '\\"').replace("\n", "\\n") + ) # fmt: skip + + assert pretty_response in response.body.decode("utf-8") + + +@pytest.mark.parametrize("app", [create_app(graphiql=True)]) +def test_graphiql_html_is_not_accepted(app): + _, response = app.test_client.get( + uri=url_string(), headers={"Accept": "application/json"} + ) + assert response.status == 400 + + +@pytest.mark.parametrize( + "app", [create_app(schema=AsyncSchema, enable_async=True, graphiql=True)] +) +def test_graphiql_enabled_async_schema(app): + query = "{a,b,c}" + _, response = app.test_client.get( + uri=url_string(query=query), headers={"Accept": "text/html"} + ) + + expected_response = ( + ( + "{\n" + ' "data": {\n' + ' "a": "hey",\n' + ' "b": "hey2",\n' + ' "c": "hey3"\n' + " }\n" + "}" + ) + .replace('"', '\\"') + .replace("\n", "\\n") + ) + + assert response.status == 200 + assert expected_response in response.body.decode("utf-8") + + +@pytest.mark.parametrize( + "app", [create_app(schema=SyncSchema, enable_async=True, graphiql=True)] +) +def test_graphiql_enabled_sync_schema(app): + query = "{a,b}" + _, response = app.test_client.get( + uri=url_string(query=query), headers={"Accept": "text/html"} + ) + + expected_response = ( + ( + "{\n" + ' "data": {\n' + ' "a": "synced_one",\n' + ' "b": "synced_two"\n' + " }\n" + "}" + ) + .replace('"', '\\"') + .replace("\n", "\\n") + ) + assert response.status == 200 + assert expected_response in response.body.decode("utf-8") diff --git a/tests/sanic/test_graphqlview.py b/tests/sanic/test_graphqlview.py new file mode 100644 index 0000000..24f2a92 --- /dev/null +++ b/tests/sanic/test_graphqlview.py @@ -0,0 +1,622 @@ +import json +from urllib.parse import urlencode + +import pytest + +from ..utils import RepeatExecutionContext +from .app import create_app, url_string +from .schema import AsyncSchema + + +def response_json(response): + return json.loads(response.body.decode()) + + +def json_dump_kwarg(**kwargs): + return json.dumps(kwargs) + + +def json_dump_kwarg_list(**kwargs): + return json.dumps([kwargs]) + + +def test_allows_get_with_query_param(app): + _, response = app.test_client.get(uri=url_string(query="{test}")) + + assert response.status == 200 + assert response_json(response) == {"data": {"test": "Hello World"}} + + +def test_allows_get_with_variable_values(app): + _, response = app.test_client.get( + uri=url_string( + query="query helloWho($who: String){ test(who: $who) }", + variables=json.dumps({"who": "Dolly"}), + ) + ) + + assert response.status == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_allows_get_with_operation_name(app): + _, response = app.test_client.get( + uri=url_string( + query=""" + query helloYou { test(who: "You"), ...shared } + query helloWorld { test(who: "World"), ...shared } + query helloDolly { test(who: "Dolly"), ...shared } + fragment shared on QueryRoot { + shared: test(who: "Everyone") + } + """, + operationName="helloWorld", + ) + ) + + assert response.status == 200 + assert response_json(response) == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +def test_reports_validation_errors(app): + _, response = app.test_client.get( + uri=url_string(query="{ test, unknownOne, unknownTwo }") + ) + + assert response.status == 400 + assert response_json(response) == { + "errors": [ + { + "message": "Cannot query field 'unknownOne' on type 'QueryRoot'.", + "locations": [{"line": 1, "column": 9}], + }, + { + "message": "Cannot query field 'unknownTwo' on type 'QueryRoot'.", + "locations": [{"line": 1, "column": 21}], + }, + ] + } + + +def test_errors_when_missing_operation_name(app): + _, response = app.test_client.get( + uri=url_string( + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + """ + ) + ) + + assert response.status == 400 + assert response_json(response) == { + "errors": [ + { + "message": "Must provide operation name" + " if query contains multiple operations.", + } + ] + } + + +def test_errors_when_sending_a_mutation_via_get(app): + _, response = app.test_client.get( + uri=url_string( + query=""" + mutation TestMutation { writeTest { test } } + """ + ) + ) + assert response.status == 405 + assert response_json(response) == { + "errors": [ + { + "message": "Can only perform a mutation operation from a POST request.", + } + ] + } + + +def test_errors_when_selecting_a_mutation_within_a_get(app): + _, response = app.test_client.get( + uri=url_string( + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + """, + operationName="TestMutation", + ) + ) + + assert response.status == 405 + assert response_json(response) == { + "errors": [ + { + "message": "Can only perform a mutation operation from a POST request.", + } + ] + } + + +def test_allows_mutation_to_exist_within_a_get(app): + _, response = app.test_client.get( + uri=url_string( + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + """, + operationName="TestQuery", + ) + ) + + assert response.status == 200 + assert response_json(response) == {"data": {"test": "Hello World"}} + + +def test_allows_post_with_json_encoding(app): + _, response = app.test_client.post( + uri=url_string(), + content=json_dump_kwarg(query="{test}"), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert response_json(response) == {"data": {"test": "Hello World"}} + + +def test_allows_sending_a_mutation_via_post(app): + _, response = app.test_client.post( + uri=url_string(), + content=json_dump_kwarg(query="mutation TestMutation { writeTest { test } }"), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert response_json(response) == {"data": {"writeTest": {"test": "Hello World"}}} + + +def test_allows_post_with_url_encoding(app): + # Example of how sanic does send data using url enconding + # can be found at their repo. + # https://github.com/huge-success/sanic/blob/master/tests/test_requests.py#L927 + payload = "query={test}" + _, response = app.test_client.post( + uri=url_string(), + content=payload, + headers={"content-type": "application/x-www-form-urlencoded"}, + ) + + assert response.status == 200 + assert response_json(response) == {"data": {"test": "Hello World"}} + + +def test_supports_post_json_query_with_string_variables(app): + _, response = app.test_client.post( + uri=url_string(), + content=json_dump_kwarg( + query="query helloWho($who: String){ test(who: $who) }", + variables=json.dumps({"who": "Dolly"}), + ), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_supports_post_json_query_with_json_variables(app): + _, response = app.test_client.post( + uri=url_string(), + content=json_dump_kwarg( + query="query helloWho($who: String){ test(who: $who) }", + variables={"who": "Dolly"}, + ), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_supports_post_url_encoded_query_with_string_variables(app): + _, response = app.test_client.post( + uri=url_string(), + content=urlencode( + { + "query": "query helloWho($who: String){ test(who: $who) }", + "variables": json.dumps({"who": "Dolly"}), + } + ), + headers={"content-type": "application/x-www-form-urlencoded"}, + ) + + assert response.status == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_supports_post_json_query_with_get_variable_values(app): + _, response = app.test_client.post( + uri=url_string(variables=json.dumps({"who": "Dolly"})), + content=json_dump_kwarg( + query="query helloWho($who: String){ test(who: $who) }", + ), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_post_url_encoded_query_with_get_variable_values(app): + _, response = app.test_client.post( + uri=url_string(variables=json.dumps({"who": "Dolly"})), + content=urlencode( + { + "query": "query helloWho($who: String){ test(who: $who) }", + } + ), + headers={"content-type": "application/x-www-form-urlencoded"}, + ) + + assert response.status == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_supports_post_raw_text_query_with_get_variable_values(app): + _, response = app.test_client.post( + uri=url_string(variables=json.dumps({"who": "Dolly"})), + content="query helloWho($who: String){ test(who: $who) }", + headers={"content-type": "application/graphql"}, + ) + + assert response.status == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_allows_post_with_operation_name(app): + _, response = app.test_client.post( + uri=url_string(), + content=json_dump_kwarg( + query=""" + query helloYou { test(who: "You"), ...shared } + query helloWorld { test(who: "World"), ...shared } + query helloDolly { test(who: "Dolly"), ...shared } + fragment shared on QueryRoot { + shared: test(who: "Everyone") + } + """, + operationName="helloWorld", + ), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert response_json(response) == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +def test_allows_post_with_get_operation_name(app): + _, response = app.test_client.post( + uri=url_string(operationName="helloWorld"), + content=""" + query helloYou { test(who: "You"), ...shared } + query helloWorld { test(who: "World"), ...shared } + query helloDolly { test(who: "Dolly"), ...shared } + fragment shared on QueryRoot { + shared: test(who: "Everyone") + } + """, + headers={"content-type": "application/graphql"}, + ) + + assert response.status == 200 + assert response_json(response) == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +@pytest.mark.parametrize("app", [create_app(pretty=True)]) +def test_supports_pretty_printing(app): + _, response = app.test_client.get(uri=url_string(query="{test}")) + + assert response.body.decode() == ( + "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" + ) + + +@pytest.mark.parametrize("app", [create_app(pretty=False)]) +def test_not_pretty_by_default(app): + _, response = app.test_client.get(url_string(query="{test}")) + + assert response.body.decode() == '{"data":{"test":"Hello World"}}' + + +def test_supports_pretty_printing_by_request(app): + _, response = app.test_client.get(uri=url_string(query="{test}", pretty="1")) + + assert response.body.decode() == ( + "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" + ) + + +def test_handles_field_errors_caught_by_graphql(app): + _, response = app.test_client.get(uri=url_string(query="{thrower}")) + assert response.status == 200 + assert response_json(response) == { + "data": None, + "errors": [ + { + "locations": [{"column": 2, "line": 1}], + "message": "Throws!", + "path": ["thrower"], + } + ], + } + + +def test_handles_syntax_errors_caught_by_graphql(app): + _, response = app.test_client.get(uri=url_string(query="syntaxerror")) + assert response.status == 400 + assert response_json(response) == { + "errors": [ + { + "locations": [{"column": 1, "line": 1}], + "message": "Syntax Error: Unexpected Name 'syntaxerror'.", + } + ] + } + + +def test_handles_errors_caused_by_a_lack_of_query(app): + _, response = app.test_client.get(uri=url_string()) + + assert response.status == 400 + assert response_json(response) == { + "errors": [{"message": "Must provide query string."}] + } + + +def test_handles_batch_correctly_if_is_disabled(app): + _, response = app.test_client.post( + uri=url_string(), content="[]", headers={"content-type": "application/json"} + ) + + assert response.status == 400 + assert response_json(response) == { + "errors": [ + { + "message": "Batch GraphQL requests are not enabled.", + } + ] + } + + +def test_handles_incomplete_json_bodies(app): + _, response = app.test_client.post( + uri=url_string(), + content='{"query":', + headers={"content-type": "application/json"}, + ) + + assert response.status == 400 + assert response_json(response) == { + "errors": [{"message": "POST body sent invalid JSON."}] + } + + +def test_handles_plain_post_text(app): + _, response = app.test_client.post( + uri=url_string(variables=json.dumps({"who": "Dolly"})), + content="query helloWho($who: String){ test(who: $who) }", + headers={"content-type": "text/plain"}, + ) + assert response.status == 400 + assert response_json(response) == { + "errors": [{"message": "Must provide query string."}] + } + + +def test_handles_poorly_formed_variables(app): + _, response = app.test_client.get( + uri=url_string( + query="query helloWho($who: String){ test(who: $who) }", variables="who:You" + ) + ) + assert response.status == 400 + assert response_json(response) == { + "errors": [{"message": "Variables are invalid JSON."}] + } + + +def test_handles_unsupported_http_methods(app): + _, response = app.test_client.put(uri=url_string(query="{test}")) + assert response.status == 405 + allowed_methods = { + method.strip() for method in response.headers["Allow"].split(",") + } + assert allowed_methods in [{"GET", "POST"}, {"HEAD", "GET", "POST", "OPTIONS"}] + assert response_json(response) == { + "errors": [ + { + "message": "GraphQL only supports GET and POST requests.", + } + ] + } + + +def test_passes_request_into_request_context(app): + _, response = app.test_client.get(uri=url_string(query="{request}", q="testing")) + + assert response.status == 200 + assert response_json(response) == {"data": {"request": "testing"}} + + +@pytest.mark.parametrize("app", [create_app(context={"session": "CUSTOM CONTEXT"})]) +def test_passes_custom_context_into_context(app): + _, response = app.test_client.get( + uri=url_string(query="{context { session request }}") + ) + + assert response.status_code == 200 + res = response_json(response) + assert "data" in res + assert "session" in res["data"]["context"] + assert "request" in res["data"]["context"] + assert "CUSTOM CONTEXT" in res["data"]["context"]["session"] + assert "Request" in res["data"]["context"]["request"] + + +class CustomContext(dict): + property = "A custom property" + + +@pytest.mark.parametrize("app", [create_app(context=CustomContext())]) +def test_allow_empty_custom_context(app): + _, response = app.test_client.get( + uri=url_string(query="{context { property request }}") + ) + + assert response.status_code == 200 + res = response_json(response) + assert "data" in res + assert "request" in res["data"]["context"] + assert "property" in res["data"]["context"] + assert "A custom property" == res["data"]["context"]["property"] + assert "Request" in res["data"]["context"]["request"] + + +@pytest.mark.parametrize("app", [create_app(context="CUSTOM CONTEXT")]) +def test_context_remapped_if_not_mapping(app): + _, response = app.test_client.get( + uri=url_string(query="{context { session request }}") + ) + + assert response.status_code == 200 + res = response_json(response) + assert "data" in res + assert "session" in res["data"]["context"] + assert "request" in res["data"]["context"] + assert "CUSTOM CONTEXT" not in res["data"]["context"]["request"] + assert "Request" in res["data"]["context"]["request"] + + +def test_post_multipart_data(app): + query = "mutation TestMutation { writeTest { test } }" + + data = ( + "------sanicgraphql\r\n" + + 'Content-Disposition: form-data; name="query"\r\n' + + "\r\n" + + query + + "\r\n" + + "------sanicgraphql--\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + 'Content-Disposition: form-data; name="file"; filename="text1.txt"; filename*=utf-8\'\'text1.txt\r\n' + + "\r\n" + + "\r\n" + + "------sanicgraphql--\r\n" + ) + + _, response = app.test_client.post( + uri=url_string(), + content=data, + headers={"content-type": "multipart/form-data; boundary=----sanicgraphql"}, + ) + + assert response.status == 200 + assert response_json(response) == {"data": {"writeTest": {"test": "Hello World"}}} + + +@pytest.mark.parametrize("app", [create_app(batch=True)]) +def test_batch_allows_post_with_json_encoding(app): + _, response = app.test_client.post( + uri=url_string(), + content=json_dump_kwarg_list(id=1, query="{test}"), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert response_json(response) == [{"data": {"test": "Hello World"}}] + + +@pytest.mark.parametrize("app", [create_app(batch=True)]) +def test_batch_supports_post_json_query_with_json_variables(app): + _, response = app.test_client.post( + uri=url_string(), + content=json_dump_kwarg_list( + id=1, + query="query helloWho($who: String){ test(who: $who) }", + variables={"who": "Dolly"}, + ), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert response_json(response) == [{"data": {"test": "Hello Dolly"}}] + + +@pytest.mark.parametrize("app", [create_app(batch=True)]) +def test_batch_allows_post_with_operation_name(app): + _, response = app.test_client.post( + uri=url_string(), + content=json_dump_kwarg_list( + id=1, + query=""" + query helloYou { test(who: "You"), ...shared } + query helloWorld { test(who: "World"), ...shared } + query helloDolly { test(who: "Dolly"), ...shared } + fragment shared on QueryRoot { + shared: test(who: "Everyone") + } + """, + operationName="helloWorld", + ), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert response_json(response) == [ + {"data": {"test": "Hello World", "shared": "Hello Everyone"}} + ] + + +@pytest.mark.parametrize("app", [create_app(schema=AsyncSchema, enable_async=True)]) +def test_async_schema(app): + query = "{a,b,c}" + _, response = app.test_client.get(uri=url_string(query=query)) + + assert response.status == 200 + assert response_json(response) == {"data": {"a": "hey", "b": "hey2", "c": "hey3"}} + + +def test_preflight_request(app): + _, response = app.test_client.options( + uri=url_string(), headers={"Access-Control-Request-Method": "POST"} + ) + + assert response.status == 200 + + +def test_preflight_incorrect_request(app): + _, response = app.test_client.options( + uri=url_string(), headers={"Access-Control-Request-Method": "OPTIONS"} + ) + + assert response.status == 400 + + +@pytest.mark.parametrize( + "app", [create_app(execution_context_class=RepeatExecutionContext)] +) +def test_custom_execution_context_class(app): + _, response = app.test_client.post( + uri=url_string(), + content=json_dump_kwarg(query="{test}"), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert response_json(response) == {"data": {"test": "Hello WorldHello World"}} diff --git a/tests/schema.py b/tests/schema.py index c60b0ed..c7665ba 100644 --- a/tests/schema.py +++ b/tests/schema.py @@ -1,15 +1,15 @@ -from graphql.type.definition import ( +from graphql import ( GraphQLArgument, GraphQLField, GraphQLNonNull, GraphQLObjectType, + GraphQLSchema, + GraphQLString, ) -from graphql.type.scalars import GraphQLString -from graphql.type.schema import GraphQLSchema -def resolve_error(*_args): - raise ValueError("Throws!") +def resolve_thrower(*_args): + raise Exception("Throws!") def resolve_request(_obj, info): @@ -20,22 +20,16 @@ def resolve_context(_obj, info): return str(info.context) -def resolve_test(_obj, _info, who="World"): - return "Hello {}".format(who) - - -NonNullString = GraphQLNonNull(GraphQLString) - QueryRootType = GraphQLObjectType( name="QueryRoot", fields={ - "error": GraphQLField(NonNullString, resolver=resolve_error), - "request": GraphQLField(NonNullString, resolver=resolve_request), - "context": GraphQLField(NonNullString, resolver=resolve_context), + "thrower": GraphQLField(GraphQLNonNull(GraphQLString), resolve=resolve_thrower), + "request": GraphQLField(GraphQLNonNull(GraphQLString), resolve=resolve_request), + "context": GraphQLField(GraphQLNonNull(GraphQLString), resolve=resolve_context), "test": GraphQLField( - GraphQLString, - {"who": GraphQLArgument(GraphQLString)}, - resolver=resolve_test, + type_=GraphQLString, + args={"who": GraphQLArgument(GraphQLString)}, + resolve=lambda obj, info, who="World": "Hello %s" % who, ), }, ) @@ -43,10 +37,9 @@ def resolve_test(_obj, _info, who="World"): MutationRootType = GraphQLObjectType( name="MutationRoot", fields={ - "writeTest": GraphQLField( - type=QueryRootType, resolver=lambda *_args: QueryRootType - ) + "writeTest": GraphQLField(type_=QueryRootType, resolve=lambda *_: QueryRootType) }, ) schema = GraphQLSchema(QueryRootType, MutationRootType) +invalid_schema = GraphQLSchema() diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index db8fc02..a0845e4 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -1,11 +1,10 @@ -from graphql.execution.executors.asyncio import AsyncioExecutor +import asyncio + from graphql.type.definition import GraphQLField, GraphQLNonNull, GraphQLObjectType from graphql.type.scalars import GraphQLString from graphql.type.schema import GraphQLSchema -from promise import Promise -import asyncio -from graphql_server import RequestParams, run_http_query +from graphql_server import GraphQLParams, run_http_query from .utils import as_dicts @@ -33,10 +32,10 @@ async def resolve_field_async(_obj, info): QueryRootType = GraphQLObjectType( name="QueryRoot", fields={ - "errorSync": GraphQLField(NonNullString, resolver=resolve_error_sync), - "errorAsync": GraphQLField(NonNullString, resolver=resolve_error_async), - "fieldSync": GraphQLField(NonNullString, resolver=resolve_field_sync), - "fieldAsync": GraphQLField(NonNullString, resolver=resolve_field_async), + "errorSync": GraphQLField(NonNullString, resolve=resolve_error_sync), + "errorAsync": GraphQLField(NonNullString, resolve=resolve_error_async), + "fieldSync": GraphQLField(NonNullString, resolve=resolve_field_sync), + "fieldAsync": GraphQLField(NonNullString, resolve=resolve_field_async), }, ) @@ -44,45 +43,20 @@ async def resolve_field_async(_obj, info): def test_get_responses_using_asyncio_executor(): - class TestExecutor(AsyncioExecutor): - called = False - waited = False - cleaned = False - - def wait_until_finished(self): - TestExecutor.waited = True - super().wait_until_finished() - - def clean(self): - TestExecutor.cleaned = True - super().clean() - - def execute(self, fn, *args, **kwargs): - TestExecutor.called = True - return super().execute(fn, *args, **kwargs) - query = "{fieldSync fieldAsync}" - loop = asyncio.get_event_loop() - async def get_results(): result_promises, params = run_http_query( - schema, - "get", - {}, - dict(query=query), - executor=TestExecutor(loop=loop), - return_promise=True, + schema, "get", {}, {"query": query}, run_sync=False ) - results = await Promise.all(result_promises) - return results, params + res = [await result for result in result_promises] + return res, params - results, params = loop.run_until_complete(get_results()) + results, params = asyncio.run(get_results()) - expected_results = [{"data": {"fieldSync": "sync", "fieldAsync": "async"}}] + expected_results = [ + {"data": {"fieldSync": "sync", "fieldAsync": "async"}, "errors": None} + ] assert as_dicts(results) == expected_results - assert params == [RequestParams(query=query, variables=None, operation_name=None)] - assert TestExecutor.called - assert not TestExecutor.waited - assert TestExecutor.cleaned + assert params == [GraphQLParams(query=query, variables=None, operation_name=None)] diff --git a/tests/test_error.py b/tests/test_error.py index a0f7017..4dfdc93 100644 --- a/tests/test_error.py +++ b/tests/test_error.py @@ -1,28 +1,34 @@ from graphql_server import HttpQueryError -def test_create_http_query_error(): - - error = HttpQueryError(420, "Some message", headers={"SomeHeader": "SomeValue"}) - assert error.status_code == 420 - assert error.message == "Some message" - assert error.headers == {"SomeHeader": "SomeValue"} +def test_can_create_http_query_error(): + error = HttpQueryError(400, "Bad error") + assert error.status_code == 400 + assert error.message == "Bad error" + assert not error.is_graphql_error + assert error.headers is None def test_compare_http_query_errors(): - - error = HttpQueryError(400, "Message", headers={"Header": "Value"}) - assert error == HttpQueryError(400, "Message", headers={"Header": "Value"}) - assert error != HttpQueryError(420, "Message", headers={"Header": "Value"}) - assert error != HttpQueryError(400, "Other Message", headers={"Header": "Value"}) - assert error != HttpQueryError(400, "Message", headers={"Header": "OtherValue"}) + error = HttpQueryError(400, "Bad error") + assert error == error + same_error = HttpQueryError(400, "Bad error") + assert error == same_error + different_error = HttpQueryError(400, "Not really bad error") + assert error != different_error + different_error = HttpQueryError(405, "Bad error") + assert error != different_error + different_error = HttpQueryError(400, "Bad error", headers={"Allow": "ALL"}) + assert error != different_error def test_hash_http_query_errors(): - - error = HttpQueryError(400, "Foo", headers={"Bar": "Baz"}) - - assert hash(error) == hash(HttpQueryError(400, "Foo", headers={"Bar": "Baz"})) - assert hash(error) != hash(HttpQueryError(420, "Foo", headers={"Bar": "Baz"})) - assert hash(error) != hash(HttpQueryError(400, "Boo", headers={"Bar": "Baz"})) - assert hash(error) != hash(HttpQueryError(400, "Foo", headers={"Bar": "Faz"})) + errors = { + HttpQueryError(400, "Bad error 1"), + HttpQueryError(400, "Bad error 2"), + HttpQueryError(403, "Bad error 1"), + } + assert HttpQueryError(400, "Bad error 1") in errors + assert HttpQueryError(400, "Bad error 2") in errors + assert HttpQueryError(403, "Bad error 1") in errors + assert HttpQueryError(403, "Bad error 2") not in errors diff --git a/tests/test_helpers.py b/tests/test_helpers.py index fc4b73e..30a0b2e 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -1,8 +1,8 @@ import json +from graphql import Source from graphql.error import GraphQLError from graphql.execution import ExecutionResult -from graphql.language.location import SourceLocation from pytest import raises from graphql_server import ( @@ -20,11 +20,6 @@ def test_json_encode(): assert result == '{"query":"{test}"}' -def test_json_encode_pretty(): - result = json_encode_pretty({"query": "{test}"}) - assert result == '{\n "query": "{test}"\n}' - - def test_json_encode_with_pretty_argument(): result = json_encode({"query": "{test}"}, pretty=False) assert result == '{"query":"{test}"}' @@ -88,7 +83,10 @@ def test_encode_execution_results_with_error(): None, [ GraphQLError( - "Some error", locations=[SourceLocation(1, 2)], path=["somePath"] + "Some error", + source=Source(body="Some error"), + positions=[1], + path=["somePath"], ) ], ), @@ -100,7 +98,6 @@ def test_encode_execution_results_with_error(): assert isinstance(output.body, str) assert isinstance(output.status_code, int) assert json.loads(output.body) == { - "data": None, "errors": [ { "message": "Some error", @@ -108,30 +105,11 @@ def test_encode_execution_results_with_error(): "path": ["somePath"], } ], + "data": None, } assert output.status_code == 200 -def test_encode_execution_results_with_invalid(): - execution_results = [ - ExecutionResult( - None, - [GraphQLError("SyntaxError", locations=[SourceLocation(1, 2)])], - invalid=True, - ), - ExecutionResult({"result": 42}, None), - ] - - output = encode_execution_results(execution_results) - assert isinstance(output, ServerResponse) - assert isinstance(output.body, str) - assert isinstance(output.status_code, int) - assert json.loads(output.body) == { - "errors": [{"message": "SyntaxError", "locations": [{"line": 1, "column": 2}]}] - } - assert output.status_code == 400 - - def test_encode_execution_results_with_empty_result(): execution_results = [None] @@ -149,7 +127,10 @@ def test_encode_execution_results_with_format_error(): None, [ GraphQLError( - "Some msg", locations=[SourceLocation(1, 2)], path=["some", "path"] + "Some msg", + source=Source("Some msg"), + positions=[1], + path=["some", "path"], ) ], ) @@ -157,8 +138,8 @@ def test_encode_execution_results_with_format_error(): def format_error(error): return { - "msg": str(error), - "loc": "{}:{}".format(error.locations[0].line, error.locations[0].column), + "msg": error.message, + "loc": f"{error.locations[0].line}:{error.locations[0].column}", "pth": "/".join(error.path), } @@ -167,8 +148,8 @@ def format_error(error): assert isinstance(output.body, str) assert isinstance(output.status_code, int) assert json.loads(output.body) == { - "data": None, "errors": [{"msg": "Some msg", "loc": "1:2", "pth": "some/path"}], + "data": None, } assert output.status_code == 200 @@ -211,88 +192,6 @@ def test_encode_execution_results_with_batch_and_empty_result(): assert output.status_code == 200 -def test_encode_execution_results_with_batch_and_error(): - execution_results = [ - ExecutionResult({"result": 1}, None), - ExecutionResult( - None, - [ - GraphQLError( - "No data here", locations=[SourceLocation(1, 2)], path=["somePath"] - ) - ], - ), - ExecutionResult({"result": 3}, None), - ] - - output = encode_execution_results(execution_results, is_batch=True) - assert isinstance(output, ServerResponse) - assert isinstance(output.body, str) - assert isinstance(output.status_code, int) - assert json.loads(output.body) == [ - {"data": {"result": 1}}, - { - "data": None, - "errors": [ - { - "message": "No data here", - "locations": [{"line": 1, "column": 2}], - "path": ["somePath"], - } - ], - }, - {"data": {"result": 3}}, - ] - assert output.status_code == 200 - - -def test_encode_execution_results_with_batch_and_invalid(): - execution_results = [ - ExecutionResult({"result": 1}, None), - ExecutionResult( - None, - [ - GraphQLError( - "No data here", locations=[SourceLocation(1, 2)], path=["somePath"] - ) - ], - ), - ExecutionResult({"result": 3}, None), - ExecutionResult( - None, - [GraphQLError("SyntaxError", locations=[SourceLocation(1, 2)])], - invalid=True, - ), - ExecutionResult({"result": 5}, None), - ] - - output = encode_execution_results(execution_results, is_batch=True) - assert isinstance(output, ServerResponse) - assert isinstance(output.body, str) - assert isinstance(output.status_code, int) - assert json.loads(output.body) == [ - {"data": {"result": 1}}, - { - "data": None, - "errors": [ - { - "message": "No data here", - "locations": [{"line": 1, "column": 2}], - "path": ["somePath"], - } - ], - }, - {"data": {"result": 3}}, - { - "errors": [ - {"message": "SyntaxError", "locations": [{"line": 1, "column": 2}]} - ] - }, - {"data": {"result": 5}}, - ] - assert output.status_code == 400 - - def test_encode_execution_results_with_encode(): execution_results = [ExecutionResult({"result": None}, None)] @@ -307,7 +206,7 @@ def encode(result): assert output.status_code == 200 -def test_encode_execution_results_with_pretty(): +def test_encode_execution_results_with_pretty_encode(): execution_results = [ExecutionResult({"test": "Hello World"}, None)] output = encode_execution_results(execution_results, encode=json_encode_pretty) diff --git a/tests/test_query.py b/tests/test_query.py index e5bbb79..e0994db 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -1,43 +1,63 @@ import json -from graphql.error import GraphQLError, GraphQLSyntaxError +from graphql.error import GraphQLError from graphql.execution import ExecutionResult -from promise import Promise +from graphql.validation import ValidationRule from pytest import raises from graphql_server import ( + GraphQLParams, + GraphQLResponse, HttpQueryError, - RequestParams, - ServerResults, encode_execution_results, + format_execution_result, json_encode, - json_encode_pretty, load_json_body, run_http_query, ) +from graphql_server.render_graphiql import ( + GraphiQLConfig, + GraphiQLData, + render_graphiql_sync, +) -from .schema import schema +from .schema import invalid_schema, schema from .utils import as_dicts def test_request_params(): - assert issubclass(RequestParams, tuple) + assert issubclass(GraphQLParams, tuple) # noinspection PyUnresolvedReferences - assert RequestParams._fields == ("query", "variables", "operation_name") + assert GraphQLParams._fields == ("query", "variables", "operation_name") def test_server_results(): - assert issubclass(ServerResults, tuple) + assert issubclass(GraphQLResponse, tuple) # noinspection PyUnresolvedReferences - assert ServerResults._fields == ("results", "params") + assert GraphQLResponse._fields == ("results", "params") + + +def test_validate_schema(): + query = "{test}" + results, params = run_http_query(invalid_schema, "get", {}, {"query": query}) + assert as_dicts(results) == [ + { + "data": None, + "errors": [ + { + "message": "Query root type must be provided.", + } + ], + } + ] def test_allows_get_with_query_param(): query = "{test}" - results, params = run_http_query(schema, "get", {}, dict(query=query)) + results, params = run_http_query(schema, "get", {}, {"query": query}) - assert as_dicts(results) == [{"data": {"test": "Hello World"}}] - assert params == [RequestParams(query=query, variables=None, operation_name=None)] + assert as_dicts(results) == [{"data": {"test": "Hello World"}, "errors": None}] + assert params == [GraphQLParams(query=query, variables=None, operation_name=None)] def test_allows_get_with_variable_values(): @@ -45,13 +65,13 @@ def test_allows_get_with_variable_values(): schema, "get", {}, - dict( - query="query helloWho($who: String){ test(who: $who) }", - variables=json.dumps({"who": "Dolly"}), - ), + { + "query": "query helloWho($who: String){ test(who: $who) }", + "variables": json.dumps({"who": "Dolly"}), + }, ) - assert as_dicts(results) == [{"data": {"test": "Hello Dolly"}}] + assert as_dicts(results) == [{"data": {"test": "Hello Dolly"}, "errors": None}] def test_allows_get_with_operation_name(): @@ -59,8 +79,8 @@ def test_allows_get_with_operation_name(): schema, "get", {}, - query_data=dict( - query=""" + query_data={ + "query": """ query helloYou { test(who: "You"), ...shared } query helloWorld { test(who: "World"), ...shared } query helloDolly { test(who: "Dolly"), ...shared } @@ -68,35 +88,100 @@ def test_allows_get_with_operation_name(): shared: test(who: "Everyone") } """, - operationName="helloWorld", - ), + "operationName": "helloWorld", + }, ) assert as_dicts(results) == [ - {"data": {"test": "Hello World", "shared": "Hello Everyone"}} + {"data": {"test": "Hello World", "shared": "Hello Everyone"}, "errors": None} ] + response = encode_execution_results(results) + assert response.status_code == 200 + def test_reports_validation_errors(): results, params = run_http_query( - schema, "get", {}, query_data=dict(query="{ test, unknownOne, unknownTwo }") + schema, "get", {}, query_data={"query": "{ test, unknownOne, unknownTwo }"} ) assert as_dicts(results) == [ { + "data": None, "errors": [ { - "message": 'Cannot query field "unknownOne" on type "QueryRoot".', + "message": "Cannot query field 'unknownOne' on type 'QueryRoot'.", "locations": [{"line": 1, "column": 9}], }, { - "message": 'Cannot query field "unknownTwo" on type "QueryRoot".', + "message": "Cannot query field 'unknownTwo' on type 'QueryRoot'.", "locations": [{"line": 1, "column": 21}], }, - ] + ], } ] + response = encode_execution_results(results) + assert response.status_code == 400 + + +def test_reports_custom_validation_errors(): + class CustomValidationRule(ValidationRule): + def enter_field(self, node, *_args): + self.report_error(GraphQLError("Custom validation error.", node)) + + results, params = run_http_query( + schema, + "get", + {}, + query_data={"query": "{ test }"}, + validation_rules=[CustomValidationRule], + ) + + assert as_dicts(results) == [ + { + "data": None, + "errors": [ + { + "message": "Custom validation error.", + "locations": [{"line": 1, "column": 3}], + } + ], + } + ] + + response = encode_execution_results(results) + assert response.status_code == 400 + + +def test_reports_max_num_of_validation_errors(): + results, params = run_http_query( + schema, + "get", + {}, + query_data={"query": "{ test, unknownOne, unknownTwo }"}, + max_errors=1, + ) + + assert as_dicts(results) == [ + { + "data": None, + "errors": [ + { + "message": "Cannot query field 'unknownOne' on type 'QueryRoot'.", + "locations": [{"line": 1, "column": 9}], + }, + { + "message": "Too many validation errors, error limit reached." + " Validation aborted.", + }, + ], + } + ] + + response = encode_execution_results(results) + assert response.status_code == 400 + def test_non_dict_params_in_non_batch_query(): with raises(HttpQueryError) as exc_info: @@ -122,24 +207,25 @@ def test_errors_when_missing_operation_name(): schema, "get", {}, - query_data=dict( - query=""" + query_data={ + "query": """ query TestQuery { test } mutation TestMutation { writeTest { test } } """ - ), + }, ) assert as_dicts(results) == [ { + "data": None, "errors": [ { "message": ( "Must provide operation name" " if query contains multiple operations." - ) + ), } - ] + ], } ] assert isinstance(results[0].errors[0], GraphQLError) @@ -151,11 +237,11 @@ def test_errors_when_sending_a_mutation_via_get(): schema, "get", {}, - query_data=dict( - query=""" + query_data={ + "query": """ mutation TestMutation { writeTest { test } } """ - ), + }, ) assert exc_info.value == HttpQueryError( @@ -170,11 +256,11 @@ def test_catching_errors_when_sending_a_mutation_via_get(): schema, "get", {}, - query_data=dict( - query=""" + query_data={ + "query": """ mutation TestMutation { writeTest { test } } """ - ), + }, catch=True, ) @@ -187,13 +273,13 @@ def test_errors_when_selecting_a_mutation_within_a_get(): schema, "get", {}, - query_data=dict( - query=""" + query_data={ + "query": """ query TestQuery { test } mutation TestMutation { writeTest { test } } """, - operationName="TestMutation", - ), + "operationName": "TestMutation", + }, ) assert exc_info.value == HttpQueryError( @@ -208,16 +294,16 @@ def test_allows_mutation_to_exist_within_a_get(): schema, "get", {}, - query_data=dict( - query=""" + query_data={ + "query": """ query TestQuery { test } mutation TestMutation { writeTest { test } } """, - operationName="TestQuery", - ), + "operationName": "TestQuery", + }, ) - assert as_dicts(results) == [{"data": {"test": "Hello World"}}] + assert as_dicts(results) == [{"data": {"test": "Hello World"}, "errors": None}] def test_allows_sending_a_mutation_via_post(): @@ -225,18 +311,16 @@ def test_allows_sending_a_mutation_via_post(): schema, "post", {}, - query_data=dict(query="mutation TestMutation { writeTest { test } }"), + query_data={"query": "mutation TestMutation { writeTest { test } }"}, ) - assert as_dicts(results) == [{"data": {"writeTest": {"test": "Hello World"}}}] + assert results == [({"writeTest": {"test": "Hello World"}}, None)] def test_allows_post_with_url_encoding(): - results, params = run_http_query( - schema, "post", {}, query_data=dict(query="{test}") - ) + results, params = run_http_query(schema, "post", {}, query_data={"query": "{test}"}) - assert as_dicts(results) == [{"data": {"test": "Hello World"}}] + assert results == [({"test": "Hello World"}, None)] def test_supports_post_json_query_with_string_variables(): @@ -244,13 +328,26 @@ def test_supports_post_json_query_with_string_variables(): schema, "post", {}, - query_data=dict( - query="query helloWho($who: String){ test(who: $who) }", - variables='{"who": "Dolly"}', - ), + query_data={ + "query": "query helloWho($who: String){ test(who: $who) }", + "variables": '{"who": "Dolly"}', + }, + ) + + assert results == [({"test": "Hello Dolly"}, None)] + + +def test_supports_post_json_query_with_json_variables(): + result = load_json_body( + """ + { + "query": "query helloWho($who: String){ test(who: $who) }", + "variables": {"who": "Dolly"} + } + """ ) - assert as_dicts(results) == [{"data": {"test": "Hello Dolly"}}] + assert result["variables"] == {"who": "Dolly"} def test_supports_post_url_encoded_query_with_string_variables(): @@ -258,54 +355,54 @@ def test_supports_post_url_encoded_query_with_string_variables(): schema, "post", {}, - query_data=dict( - query="query helloWho($who: String){ test(who: $who) }", - variables='{"who": "Dolly"}', - ), + query_data={ + "query": "query helloWho($who: String){ test(who: $who) }", + "variables": '{"who": "Dolly"}', + }, ) - assert as_dicts(results) == [{"data": {"test": "Hello Dolly"}}] + assert results == [({"test": "Hello Dolly"}, None)] def test_supports_post_json_query_with_get_variable_values(): results, params = run_http_query( schema, "post", - data=dict(query="query helloWho($who: String){ test(who: $who) }"), - query_data=dict(variables={"who": "Dolly"}), + data={"query": "query helloWho($who: String){ test(who: $who) }"}, + query_data={"variables": {"who": "Dolly"}}, ) - assert as_dicts(results) == [{"data": {"test": "Hello Dolly"}}] + assert results == [({"test": "Hello Dolly"}, None)] def test_post_url_encoded_query_with_get_variable_values(): results, params = run_http_query( schema, "get", - data=dict(query="query helloWho($who: String){ test(who: $who) }"), - query_data=dict(variables='{"who": "Dolly"}'), + data={"query": "query helloWho($who: String){ test(who: $who) }"}, + query_data={"variables": '{"who": "Dolly"}'}, ) - assert as_dicts(results) == [{"data": {"test": "Hello Dolly"}}] + assert results == [({"test": "Hello Dolly"}, None)] def test_supports_post_raw_text_query_with_get_variable_values(): results, params = run_http_query( schema, "get", - data=dict(query="query helloWho($who: String){ test(who: $who) }"), - query_data=dict(variables='{"who": "Dolly"}'), + data={"query": "query helloWho($who: String){ test(who: $who) }"}, + query_data={"variables": '{"who": "Dolly"}'}, ) - assert as_dicts(results) == [{"data": {"test": "Hello Dolly"}}] + assert results == [({"test": "Hello Dolly"}, None)] def test_allows_post_with_operation_name(): results, params = run_http_query( schema, "get", - data=dict( - query=""" + data={ + "query": """ query helloYou { test(who: "You"), ...shared } query helloWorld { test(who: "World"), ...shared } query helloDolly { test(who: "Dolly"), ...shared } @@ -313,21 +410,19 @@ def test_allows_post_with_operation_name(): shared: test(who: "Everyone") } """, - operationName="helloWorld", - ), + "operationName": "helloWorld", + }, ) - assert as_dicts(results) == [ - {"data": {"test": "Hello World", "shared": "Hello Everyone"}} - ] + assert results == [({"test": "Hello World", "shared": "Hello Everyone"}, None)] def test_allows_post_with_get_operation_name(): results, params = run_http_query( schema, "get", - data=dict( - query=""" + data={ + "query": """ query helloYou { test(who: "You"), ...shared } query helloWorld { test(who: "World"), ...shared } query helloDolly { test(who: "Dolly"), ...shared } @@ -335,59 +430,53 @@ def test_allows_post_with_get_operation_name(): shared: test(who: "Everyone") } """ - ), - query_data=dict(operationName="helloWorld"), + }, + query_data={"operationName": "helloWorld"}, ) - assert as_dicts(results) == [ - {"data": {"test": "Hello World", "shared": "Hello Everyone"}} - ] + assert results == [({"test": "Hello World", "shared": "Hello Everyone"}, None)] def test_supports_pretty_printing_data(): - results, params = run_http_query(schema, "get", dict(query="{test}")) - body = encode_execution_results(results, encode=json_encode_pretty).body + results, params = run_http_query(schema, "get", data={"query": "{test}"}) + result = {"data": results[0].data} - assert body == "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" + assert json_encode(result, pretty=True) == ( + "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" + ) def test_not_pretty_data_by_default(): - results, params = run_http_query(schema, "get", dict(query="{test}")) - body = encode_execution_results(results).body + results, params = run_http_query(schema, "get", data={"query": "{test}"}) + result = {"data": results[0].data} - assert body == '{"data":{"test":"Hello World"}}' + assert json_encode(result) == '{"data":{"test":"Hello World"}}' def test_handles_field_errors_caught_by_graphql(): - results, params = run_http_query(schema, "get", dict(query="{error}")) + results, params = run_http_query(schema, "get", data={"query": "{thrower}"}) - assert as_dicts(results) == [ - { - "data": None, - "errors": [ - { - "message": "Throws!", - "locations": [{"line": 1, "column": 2}], - "path": ["error"], - } - ], - } + assert results == [ + (None, [{"message": "Throws!", "locations": [(1, 2)], "path": ["thrower"]}]) ] + response = encode_execution_results(results) + assert response.status_code == 200 + def test_handles_syntax_errors_caught_by_graphql(): - results, params = run_http_query(schema, "get", dict(query="syntaxerror")) + results, params = run_http_query(schema, "get", data={"query": "syntaxerror"}) - assert as_dicts(results) == [ - { - "errors": [ + assert results == [ + ( + None, + [ { - "locations": [{"line": 1, "column": 1}], - "message": "Syntax Error GraphQL (1:1)" - ' Unexpected Name "syntaxerror"\n\n1: syntaxerror\n ^\n', + "locations": [(1, 1)], + "message": "Syntax Error: Unexpected Name 'syntaxerror'.", } - ] - } + ], + ) ] @@ -399,11 +488,10 @@ def test_handles_errors_caused_by_a_lack_of_query(): def test_handles_errors_caused_by_invalid_query_type(): - results, params = run_http_query(schema, "get", dict(query=42)) + with raises(HttpQueryError) as exc_info: + results, params = run_http_query(schema, "get", {"query": 42}) - assert as_dicts(results) == [ - {"errors": [{"message": "The query must be a string"}]} - ] + assert exc_info.value == HttpQueryError(400, "Unexpected query type.") def test_handles_batch_correctly_if_is_disabled(): @@ -435,10 +523,10 @@ def test_handles_poorly_formed_variables(): schema, "get", {}, - dict( - query="query helloWho($who: String){ test(who: $who) }", - variables="who:You", - ), + { + "query": "query helloWho($who: String){ test(who: $who) }", + "variables": "who:You", + }, ) assert exc_info.value == HttpQueryError(400, "Variables are invalid JSON.") @@ -447,10 +535,11 @@ def test_handles_poorly_formed_variables(): def test_handles_bad_schema(): with raises(TypeError) as exc_info: # noinspection PyTypeChecker - run_http_query("not a schema", "get", {"query": "{error}"}) # type: ignore + run_http_query("not a schema", "get", {}) # type: ignore - msg = str(exc_info.value) - assert msg == "Expected a GraphQL schema, but received 'not a schema'." + assert str(exc_info.value) == ( + "Expected a GraphQL schema, but received 'not a schema'." + ) def test_handles_unsupported_http_methods(): @@ -464,12 +553,53 @@ def test_handles_unsupported_http_methods(): ) +def test_format_execution_result(): + result = format_execution_result(None) + assert result == GraphQLResponse(None, 200) + data = {"answer": 42} + result = format_execution_result(ExecutionResult(data, None)) + assert result == GraphQLResponse({"data": data}, 200) + errors = [GraphQLError("bad")] + result = format_execution_result(ExecutionResult(None, errors)) + assert result == GraphQLResponse({"errors": errors}, 400) + + +def test_encode_execution_results(): + data = {"answer": 42} + errors = [GraphQLError("bad")] + results = [ExecutionResult(data, None), ExecutionResult(None, errors)] + result = encode_execution_results(results) + assert result == ('{"data":{"answer":42}}', 400) + + +def test_encode_execution_results_batch(): + data = {"answer": 42} + errors = [GraphQLError("bad")] + results = [ExecutionResult(data, None), ExecutionResult(None, errors)] + result = encode_execution_results(results, is_batch=True) + assert result == ( + '[{"data":{"answer":42}},{"errors":[{"message":"bad"}]}]', + 400, + ) + + +def test_encode_execution_results_not_encoded(): + data = {"answer": 42} + results = [ExecutionResult(data, None)] + result = encode_execution_results(results, encode=lambda r: r) + assert result == ({"data": data}, 200) + + def test_passes_request_into_request_context(): results, params = run_http_query( - schema, "get", {}, dict(query="{request}"), context_value={"q": "testing"} + schema, + "get", + {}, + query_data={"query": "{request}"}, + context_value={"q": "testing"}, ) - assert as_dicts(results) == [{"data": {"request": "testing"}}] + assert results == [({"request": "testing"}, None)] def test_supports_pretty_printing_context(): @@ -478,24 +608,24 @@ def __str__(self): return "CUSTOM CONTEXT" results, params = run_http_query( - schema, "get", {}, dict(query="{context}"), context_value=Context() + schema, "get", {}, query_data={"query": "{context}"}, context_value=Context() ) - assert as_dicts(results) == [{"data": {"context": "CUSTOM CONTEXT"}}] + assert results == [({"context": "CUSTOM CONTEXT"}, None)] def test_post_multipart_data(): query = "mutation TestMutation { writeTest { test } }" - results, params = run_http_query(schema, "post", {}, query_data=dict(query=query)) + results, params = run_http_query(schema, "post", {}, query_data={"query": query}) - assert as_dicts(results) == [{"data": {"writeTest": {"test": "Hello World"}}}] + assert results == [({"writeTest": {"test": "Hello World"}}, None)] def test_batch_allows_post_with_json_encoding(): data = load_json_body('[{"query": "{test}"}]') results, params = run_http_query(schema, "post", data, batch_enabled=True) - assert as_dicts(results) == [{"data": {"test": "Hello World"}}] + assert results == [({"test": "Hello World"}, None)] def test_batch_supports_post_json_query_with_json_variables(): @@ -505,13 +635,13 @@ def test_batch_supports_post_json_query_with_json_variables(): ) results, params = run_http_query(schema, "post", data, batch_enabled=True) - assert as_dicts(results) == [{"data": {"test": "Hello Dolly"}}] + assert results == [({"test": "Hello Dolly"}, None)] def test_batch_allows_post_with_operation_name(): data = [ - dict( - query=""" + { + "query": """ query helloYou { test(who: "You"), ...shared } query helloWorld { test(who: "World"), ...shared } query helloDolly { test(who: "Dolly"), ...shared } @@ -519,130 +649,28 @@ def test_batch_allows_post_with_operation_name(): shared: test(who: "Everyone") } """, - operationName="helloWorld", - ) + "operationName": "helloWorld", + } ] data = load_json_body(json_encode(data)) results, params = run_http_query(schema, "post", data, batch_enabled=True) - assert as_dicts(results) == [ - {"data": {"test": "Hello World", "shared": "Hello Everyone"}} - ] - - -def test_get_responses_using_executor(): - class TestExecutor(object): - called = False - waited = False - cleaned = False - - def wait_until_finished(self): - TestExecutor.waited = True + assert results == [({"test": "Hello World", "shared": "Hello Everyone"}, None)] - def clean(self): - TestExecutor.cleaned = True - def execute(self, fn, *args, **kwargs): - TestExecutor.called = True - return fn(*args, **kwargs) - - query = "{test}" +def test_graphiql_render_umlaut(): results, params = run_http_query( - schema, "get", {}, dict(query=query), executor=TestExecutor(), - ) - - assert isinstance(results, list) - assert len(results) == 1 - assert isinstance(results[0], ExecutionResult) - - assert as_dicts(results) == [{"data": {"test": "Hello World"}}] - assert params == [RequestParams(query=query, variables=None, operation_name=None)] - assert TestExecutor.called - assert TestExecutor.waited - assert not TestExecutor.cleaned - - -def test_get_responses_using_executor_return_promise(): - class TestExecutor(object): - called = False - waited = False - cleaned = False - - def wait_until_finished(self): - TestExecutor.waited = True - - def clean(self): - TestExecutor.cleaned = True - - def execute(self, fn, *args, **kwargs): - TestExecutor.called = True - return fn(*args, **kwargs) - - query = "{test}" - result_promises, params = run_http_query( schema, "get", - {}, - dict(query=query), - executor=TestExecutor(), - return_promise=True, + data={"query": "query helloWho($who: String){ test(who: $who) }"}, + query_data={"variables": '{"who": "Björn"}'}, + catch=True, ) + result, status_code = encode_execution_results(results) - assert isinstance(result_promises, list) - assert len(result_promises) == 1 - assert isinstance(result_promises[0], Promise) - results = Promise.all(result_promises).get() - - assert as_dicts(results) == [{"data": {"test": "Hello World"}}] - assert params == [RequestParams(query=query, variables=None, operation_name=None)] - assert TestExecutor.called - assert not TestExecutor.waited - assert TestExecutor.cleaned - + assert status_code == 200 -def test_syntax_error_using_executor_return_promise(): - class TestExecutor(object): - called = False - waited = False - cleaned = False - - def wait_until_finished(self): - TestExecutor.waited = True - - def clean(self): - TestExecutor.cleaned = True - - def execute(self, fn, *args, **kwargs): - TestExecutor.called = True - return fn(*args, **kwargs) - - query = "this is a syntax error" - result_promises, params = run_http_query( - schema, - "get", - {}, - dict(query=query), - executor=TestExecutor(), - return_promise=True, - ) + graphiql_data = GraphiQLData(result=result, query=params[0].query) + source = render_graphiql_sync(data=graphiql_data, config=GraphiQLConfig()) - assert isinstance(result_promises, list) - assert len(result_promises) == 1 - assert isinstance(result_promises[0], Promise) - results = Promise.all(result_promises).get() - - assert isinstance(results, list) - assert len(results) == 1 - result = results[0] - assert isinstance(result, ExecutionResult) - - assert result.data is None - assert isinstance(result.errors, list) - assert len(result.errors) == 1 - error = result.errors[0] - assert isinstance(error, GraphQLSyntaxError) - - assert params == [RequestParams(query=query, variables=None, operation_name=None)] - assert not TestExecutor.called - assert not TestExecutor.waited - assert not TestExecutor.cleaned + assert "Hello Bj\\\\u00f6rn" in source diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..0ac89e5 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,50 @@ +import packaging +from packaging.version import Version + +from graphql_server.version import version, version_info + +RELEASE_LEVEL = {"alpha": "a", "beta": "b", "rc": "rc", "final": None} + + +parsed_version = Version(version) + + +def test_valid_version() -> None: + packaging.version.parse(version) + + +def test_valid_version_info() -> None: + """version_info has to be a tuple[int, int, int, str, int]""" + assert isinstance(version_info, tuple) + assert len(version_info) == 5 + + major, minor, micro, release_level, serial = version_info + assert isinstance(major, int) + assert isinstance(minor, int) + assert isinstance(micro, int) + assert isinstance(release_level, str) + assert isinstance(serial, int) + + +def test_valid_version_release_level() -> None: + if parsed_version.pre is not None: + valid_release_levels = {v for v in RELEASE_LEVEL.values() if v is not None} + assert parsed_version.pre[0] in valid_release_levels + + +def test_valid_version_info_release_level() -> None: + assert version_info[3] in RELEASE_LEVEL.keys() + + +def test_version_same_as_version_info() -> None: + assert ( + parsed_version.major, + parsed_version.minor, + parsed_version.micro, + ) == version_info[:3] + + release_level, serial = version_info[-2:] + if parsed_version.is_prerelease: + assert (RELEASE_LEVEL[release_level], serial) == parsed_version.pre + else: + assert (release_level, serial) == ("final", 0) diff --git a/tests/utils.py b/tests/utils.py index 136f09f..ee082d4 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,3 +1,23 @@ -def as_dicts(results): +from typing import List + +from graphql import ExecutionResult +from graphql.execution import ExecutionContext + + +def as_dicts(results: List[ExecutionResult]): """Convert execution results to a list of tuples of dicts for better comparison.""" - return [result.to_dict(dict_class=dict) for result in results] + return [ + { + "data": result.data, + "errors": [error.formatted for error in result.errors] + if result.errors + else result.errors, + } + for result in results + ] + + +class RepeatExecutionContext(ExecutionContext): + def execute_field(self, parent_type, source, field_nodes, path): + result = super().execute_field(parent_type, source, field_nodes, path) + return result * 2 diff --git a/tests/webob/__init__.py b/tests/webob/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/webob/app.py b/tests/webob/app.py new file mode 100644 index 0000000..fffbc88 --- /dev/null +++ b/tests/webob/app.py @@ -0,0 +1,42 @@ +from urllib.parse import urlencode + +from webob import Request + +from graphql_server.webob import GraphQLView + +from .schema import Schema + + +def url_string(url="/graphql", **url_params): + return f"{url}?{urlencode(url_params)}" if url_params else url + + +class Client: + def __init__(self, **kwargs): + self.schema = kwargs.pop("schema", None) or Schema + self.settings = kwargs.pop("settings", None) or {} + + def get(self, url, **extra): + request = Request.blank(url, method="GET", **extra) + context = self.settings.pop("context", request) + response = GraphQLView( + request=request, schema=self.schema, context=context, **self.settings + ) + return response.dispatch_request(request) + + def post(self, url, **extra): + extra["POST"] = extra.pop("data") + request = Request.blank(url, method="POST", **extra) + context = self.settings.pop("context", request) + response = GraphQLView( + request=request, schema=self.schema, context=context, **self.settings + ) + return response.dispatch_request(request) + + def put(self, url, **extra): + request = Request.blank(url, method="PUT", **extra) + context = self.settings.pop("context", request) + response = GraphQLView( + request=request, schema=self.schema, context=context, **self.settings + ) + return response.dispatch_request(request) diff --git a/tests/webob/conftest.py b/tests/webob/conftest.py new file mode 100644 index 0000000..12897d1 --- /dev/null +++ b/tests/webob/conftest.py @@ -0,0 +1,13 @@ +import pytest + +from .app import Client + + +@pytest.fixture +def settings(): + return {} + + +@pytest.fixture +def client(settings): + return Client(settings=settings) diff --git a/tests/webob/schema.py b/tests/webob/schema.py new file mode 100644 index 0000000..e94f596 --- /dev/null +++ b/tests/webob/schema.py @@ -0,0 +1,55 @@ +from graphql.type.definition import ( + GraphQLArgument, + GraphQLField, + GraphQLNonNull, + GraphQLObjectType, +) +from graphql.type.scalars import GraphQLString +from graphql.type.schema import GraphQLSchema + + +def resolve_raises(*_): + raise Exception("Throws!") + + +# Sync schema +QueryRootType = GraphQLObjectType( + name="QueryRoot", + fields={ + "thrower": GraphQLField(GraphQLNonNull(GraphQLString), resolve=resolve_raises), + "request": GraphQLField( + GraphQLNonNull(GraphQLString), + resolve=lambda obj, info: info.context["request"].params.get("q"), + ), + "context": GraphQLField( + GraphQLObjectType( + name="context", + fields={ + "session": GraphQLField(GraphQLString), + "request": GraphQLField( + GraphQLNonNull(GraphQLString), + resolve=lambda obj, info: info.context["request"], + ), + "property": GraphQLField( + GraphQLString, resolve=lambda obj, info: info.context.property + ), + }, + ), + resolve=lambda obj, info: info.context, + ), + "test": GraphQLField( + type_=GraphQLString, + args={"who": GraphQLArgument(GraphQLString)}, + resolve=lambda obj, info, who=None: "Hello %s" % (who or "World"), + ), + }, +) + +MutationRootType = GraphQLObjectType( + name="MutationRoot", + fields={ + "writeTest": GraphQLField(type_=QueryRootType, resolve=lambda *_: QueryRootType) + }, +) + +Schema = GraphQLSchema(QueryRootType, MutationRootType) diff --git a/tests/webob/test_graphiqlview.py b/tests/webob/test_graphiqlview.py new file mode 100644 index 0000000..3e6ead2 --- /dev/null +++ b/tests/webob/test_graphiqlview.py @@ -0,0 +1,36 @@ +import pytest +from jinja2 import Environment + +from .app import url_string + + +@pytest.mark.parametrize( + "settings", [{"graphiql": True}, {"graphiql": True, "jinja_env": Environment()}] +) +def test_graphiql_is_enabled(client): + response = client.get(url_string(query="{test}"), headers={"Accept": "text/html"}) + assert response.status_code == 200 + + +@pytest.mark.parametrize( + "settings", [{"graphiql": True}, {"graphiql": True, "jinja_env": Environment()}] +) +def test_graphiql_simple_renderer(client): + response = client.get(url_string(query="{test}"), headers={"Accept": "text/html"}) + assert response.status_code == 200 + pretty_response = ( + "{\n" + ' "data": {\n' + ' "test": "Hello World"\n' + " }\n" + "}".replace('"', '\\"').replace("\n", "\\n") + ) # fmt: skip + assert pretty_response in response.body.decode("utf-8") + + +@pytest.mark.parametrize( + "settings", [{"graphiql": True}, {"graphiql": True, "jinja_env": Environment()}] +) +def test_graphiql_html_is_not_accepted(client): + response = client.get(url_string(), headers={"Accept": "application/json"}) + assert response.status_code == 400 diff --git a/tests/webob/test_graphqlview.py b/tests/webob/test_graphqlview.py new file mode 100644 index 0000000..5acacd3 --- /dev/null +++ b/tests/webob/test_graphqlview.py @@ -0,0 +1,587 @@ +import json +from urllib.parse import urlencode + +import pytest + +from ..utils import RepeatExecutionContext +from .app import url_string + + +def response_json(response): + return json.loads(response.body.decode()) + + +def json_dump_kwarg(**kwargs): + return json.dumps(kwargs) + + +def json_dump_kwarg_list(**kwargs): + return json.dumps([kwargs]) + + +def test_allows_get_with_query_param(client): + response = client.get(url_string(query="{test}")) + assert response.status_code == 200, response.status + assert response_json(response) == {"data": {"test": "Hello World"}} + + +def test_allows_get_with_variable_values(client): + response = client.get( + url_string( + query="query helloWho($who: String){ test(who: $who) }", + variables=json.dumps({"who": "Dolly"}), + ) + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_allows_get_with_operation_name(client): + response = client.get( + url_string( + query=""" + query helloYou { test(who: "You"), ...shared } + query helloWorld { test(who: "World"), ...shared } + query helloDolly { test(who: "Dolly"), ...shared } + fragment shared on QueryRoot { + shared: test(who: "Everyone") + } + """, + operationName="helloWorld", + ) + ) + + assert response.status_code == 200 + assert response_json(response) == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +def test_reports_validation_errors(client): + response = client.get(url_string(query="{ test, unknownOne, unknownTwo }")) + + assert response.status_code == 400 + assert response_json(response) == { + "errors": [ + { + "message": "Cannot query field 'unknownOne' on type 'QueryRoot'.", + "locations": [{"line": 1, "column": 9}], + }, + { + "message": "Cannot query field 'unknownTwo' on type 'QueryRoot'.", + "locations": [{"line": 1, "column": 21}], + }, + ] + } + + +def test_errors_when_missing_operation_name(client): + response = client.get( + url_string( + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + """ + ) + ) + + assert response.status_code == 400 + assert response_json(response) == { + "errors": [ + { + "message": "Must provide operation name" + " if query contains multiple operations.", + } + ] + } + + +def test_errors_when_sending_a_mutation_via_get(client): + response = client.get( + url_string( + query=""" + mutation TestMutation { writeTest { test } } + """ + ) + ) + assert response.status_code == 405 + assert response_json(response) == { + "errors": [ + { + "message": "Can only perform a mutation operation from a POST request.", + } + ] + } + + +def test_errors_when_selecting_a_mutation_within_a_get(client): + response = client.get( + url_string( + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + """, + operationName="TestMutation", + ) + ) + + assert response.status_code == 405 + assert response_json(response) == { + "errors": [ + { + "message": "Can only perform a mutation operation from a POST request.", + } + ] + } + + +def test_allows_mutation_to_exist_within_a_get(client): + response = client.get( + url_string( + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + """, + operationName="TestQuery", + ) + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello World"}} + + +def test_allows_post_with_json_encoding(client): + response = client.post( + url_string(), + data=json_dump_kwarg(query="{test}"), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello World"}} + + +def test_allows_sending_a_mutation_via_post(client): + response = client.post( + url_string(), + data=json_dump_kwarg(query="mutation TestMutation { writeTest { test } }"), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"writeTest": {"test": "Hello World"}}} + + +def test_allows_post_with_url_encoding(client): + response = client.post( + url_string(), + data=urlencode({"query": "{test}"}), + content_type="application/x-www-form-urlencoded", + ) + + # assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello World"}} + + +def test_supports_post_json_query_with_string_variables(client): + response = client.post( + url_string(), + data=json_dump_kwarg( + query="query helloWho($who: String){ test(who: $who) }", + variables=json.dumps({"who": "Dolly"}), + ), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_supports_post_json_query_with_json_variables(client): + response = client.post( + url_string(), + data=json_dump_kwarg( + query="query helloWho($who: String){ test(who: $who) }", + variables={"who": "Dolly"}, + ), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_supports_post_url_encoded_query_with_string_variables(client): + response = client.post( + url_string(), + data=urlencode( + { + "query": "query helloWho($who: String){ test(who: $who) }", + "variables": json.dumps({"who": "Dolly"}), + } + ), + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_supports_post_json_quey_with_get_variable_values(client): + response = client.post( + url_string(variables=json.dumps({"who": "Dolly"})), + data=json_dump_kwarg( + query="query helloWho($who: String){ test(who: $who) }", + ), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_post_url_encoded_query_with_get_variable_values(client): + response = client.post( + url_string(variables=json.dumps({"who": "Dolly"})), + data=urlencode( + { + "query": "query helloWho($who: String){ test(who: $who) }", + } + ), + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_supports_post_raw_text_query_with_get_variable_values(client): + response = client.post( + url_string(variables=json.dumps({"who": "Dolly"})), + data="query helloWho($who: String){ test(who: $who) }", + content_type="application/graphql", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_allows_post_with_operation_name(client): + response = client.post( + url_string(), + data=json_dump_kwarg( + query=""" + query helloYou { test(who: "You"), ...shared } + query helloWorld { test(who: "World"), ...shared } + query helloDolly { test(who: "Dolly"), ...shared } + fragment shared on QueryRoot { + shared: test(who: "Everyone") + } + """, + operationName="helloWorld", + ), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +def test_allows_post_with_get_operation_name(client): + response = client.post( + url_string(operationName="helloWorld"), + data=""" + query helloYou { test(who: "You"), ...shared } + query helloWorld { test(who: "World"), ...shared } + query helloDolly { test(who: "Dolly"), ...shared } + fragment shared on QueryRoot { + shared: test(who: "Everyone") + } + """, + content_type="application/graphql", + ) + + assert response.status_code == 200 + assert response_json(response) == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +@pytest.mark.parametrize("settings", [{"pretty": True}]) +def test_supports_pretty_printing(client, settings): + response = client.get(url_string(query="{test}")) + + assert response.body.decode() == ( + "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" + ) + + +@pytest.mark.parametrize("settings", [{"pretty": False}]) +def test_not_pretty_by_default(client, settings): + response = client.get(url_string(query="{test}")) + + assert response.body.decode() == '{"data":{"test":"Hello World"}}' + + +def test_supports_pretty_printing_by_request(client): + response = client.get(url_string(query="{test}", pretty="1")) + + assert response.body.decode() == ( + "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" + ) + + +def test_handles_field_errors_caught_by_graphql(client): + response = client.get(url_string(query="{thrower}")) + assert response.status_code == 200 + assert response_json(response) == { + "data": None, + "errors": [ + { + "message": "Throws!", + "locations": [{"column": 2, "line": 1}], + "path": ["thrower"], + } + ], + } + + +def test_handles_syntax_errors_caught_by_graphql(client): + response = client.get(url_string(query="syntaxerror")) + assert response.status_code == 400 + assert response_json(response) == { + "errors": [ + { + "message": "Syntax Error: Unexpected Name 'syntaxerror'.", + "locations": [{"column": 1, "line": 1}], + } + ] + } + + +def test_handles_errors_caused_by_a_lack_of_query(client): + response = client.get(url_string()) + + assert response.status_code == 400 + assert response_json(response) == { + "errors": [{"message": "Must provide query string."}] + } + + +def test_handles_batch_correctly_if_is_disabled(client): + response = client.post(url_string(), data="[]", content_type="application/json") + + assert response.status_code == 400 + assert response_json(response) == { + "errors": [ + { + "message": "Batch GraphQL requests are not enabled.", + } + ] + } + + +def test_handles_incomplete_json_bodies(client): + response = client.post( + url_string(), data='{"query":', content_type="application/json" + ) + + assert response.status_code == 400 + assert response_json(response) == { + "errors": [{"message": "POST body sent invalid JSON."}] + } + + +def test_handles_plain_post_text(client): + response = client.post( + url_string(variables=json.dumps({"who": "Dolly"})), + data="query helloWho($who: String){ test(who: $who) }", + content_type="text/plain", + ) + assert response.status_code == 400 + assert response_json(response) == { + "errors": [{"message": "Must provide query string."}] + } + + +def test_handles_poorly_formed_variables(client): + response = client.get( + url_string( + query="query helloWho($who: String){ test(who: $who) }", variables="who:You" + ) + ) + assert response.status_code == 400 + assert response_json(response) == { + "errors": [{"message": "Variables are invalid JSON."}] + } + + +def test_handles_unsupported_http_methods(client): + response = client.put(url_string(query="{test}")) + assert response.status_code == 405 + assert response.headers["Allow"] in ["GET, POST", "HEAD, GET, POST, OPTIONS"] + assert response_json(response) == { + "errors": [{"message": "GraphQL only supports GET and POST requests."}] + } + + +def test_passes_request_into_request_context(client): + response = client.get(url_string(query="{request}", q="testing")) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"request": "testing"}} + + +@pytest.mark.parametrize("settings", [{"context": {"session": "CUSTOM CONTEXT"}}]) +def test_passes_custom_context_into_context(client, settings): + response = client.get(url_string(query="{context { session request }}")) + + assert response.status_code == 200 + res = response_json(response) + assert "data" in res + assert "session" in res["data"]["context"] + assert "request" in res["data"]["context"] + assert "CUSTOM CONTEXT" in res["data"]["context"]["session"] + assert "request" in res["data"]["context"]["request"] + + +@pytest.mark.parametrize("settings", [{"context": "CUSTOM CONTEXT"}]) +def test_context_remapped_if_not_mapping(client, settings): + response = client.get(url_string(query="{context { session request }}")) + + assert response.status_code == 200 + res = response_json(response) + assert "data" in res + assert "session" in res["data"]["context"] + assert "request" in res["data"]["context"] + assert "CUSTOM CONTEXT" not in res["data"]["context"]["request"] + assert "request" in res["data"]["context"]["request"] + + +class CustomContext(dict): + property = "A custom property" + + +@pytest.mark.parametrize("settings", [{"context": CustomContext()}]) +def test_allow_empty_custom_context(client, settings): + response = client.get(url_string(query="{context { property request }}")) + + assert response.status_code == 200 + res = response_json(response) + assert "data" in res + assert "request" in res["data"]["context"] + assert "property" in res["data"]["context"] + assert "A custom property" == res["data"]["context"]["property"] + assert "request" in res["data"]["context"]["request"] + + +def test_post_multipart_data(client): + query = "mutation TestMutation { writeTest { test } }" + data = ( + "------webobgraphql\r\n" + + 'Content-Disposition: form-data; name="query"\r\n' + + "\r\n" + + query + + "\r\n" + + "------webobgraphql--\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + 'Content-Disposition: form-data; name="file"; filename="text1.txt"; filename*=utf-8\'\'text1.txt\r\n' + + "\r\n" + + "\r\n" + + "------webobgraphql--\r\n" + ) + + response = client.post( + url_string(), + data=data, + content_type="multipart/form-data; boundary=----webobgraphql", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"writeTest": {"test": "Hello World"}}} + + +@pytest.mark.parametrize("settings", [{"batch": True}]) +def test_batch_allows_post_with_json_encoding(client, settings): + response = client.post( + url_string(), + data=json_dump_kwarg_list( + # id=1, + query="{test}" + ), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == [ + { + # 'id': 1, + "data": {"test": "Hello World"} + } + ] + + +@pytest.mark.parametrize("settings", [{"batch": True}]) +def test_batch_supports_post_json_query_with_json_variables(client, settings): + response = client.post( + url_string(), + data=json_dump_kwarg_list( + # id=1, + query="query helloWho($who: String){ test(who: $who) }", + variables={"who": "Dolly"}, + ), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == [ + { + # 'id': 1, + "data": {"test": "Hello Dolly"} + } + ] + + +@pytest.mark.parametrize("settings", [{"batch": True}]) +def test_batch_allows_post_with_operation_name(client, settings): + response = client.post( + url_string(), + data=json_dump_kwarg_list( + # id=1, + query=""" + query helloYou { test(who: "You"), ...shared } + query helloWorld { test(who: "World"), ...shared } + query helloDolly { test(who: "Dolly"), ...shared } + fragment shared on QueryRoot { + shared: test(who: "Everyone") + } + """, + operationName="helloWorld", + ), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == [ + { + # 'id': 1, + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + ] + + +@pytest.mark.parametrize( + "settings", [{"execution_context_class": RepeatExecutionContext}] +) +def test_custom_execution_context_class(client): + response = client.post( + url_string(), + data=json_dump_kwarg(query="{test}"), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello WorldHello World"}} diff --git a/tox.ini b/tox.ini index 77a2bb6..b44f66b 100644 --- a/tox.ini +++ b/tox.ini @@ -1,10 +1,18 @@ [tox] -envlist = - black,flake8,import-order,mypy,manifest, - py{27,35,36,37,38,39-dev,py,py3} +envlist = + pre-commit,mypy, + py{38,39,310,311} ; requires = tox-conda +[gh-actions] +python = + 3.8: py38 + 3.9: py39 + 3.10: py310 + 3.11: py311 + [testenv] +conda_channels = conda-forge passenv = * setenv = PYTHONPATH = {toxinidir} @@ -14,34 +22,17 @@ whitelist_externals = python commands = pip install -U setuptools - pytest --cov-report=term-missing --cov=graphql_server tests {posargs} - -[testenv:black] -basepython=python3.6 -deps = -e.[dev] -commands = - black --check graphql_server tests + py{38,39,310}: pytest tests {posargs} + py{311}: pytest tests --cov-report=term-missing --cov=graphql_server {posargs} -[testenv:flake8] -basepython=python3.6 -deps = -e.[dev] +[testenv:pre-commit] +skip_install = true +deps = pre-commit commands = - flake8 setup.py graphql_server tests - -[testenv:import-order] -basepython=python3.6 -deps = -e.[dev] -commands = - isort -rc graphql_server/ tests/ + pre-commit run --all-files --show-diff-on-failure {posargs} [testenv:mypy] -basepython=python3.6 +basepython = python3.11 deps = -e.[dev] commands = mypy graphql_server tests --ignore-missing-imports - -[testenv:manifest] -basepython = python3.6 -deps = -e.[dev] -commands = - check-manifest -v