diff --git a/MANIFEST.in b/MANIFEST.in index a6c003d..efabb30 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -7,9 +7,10 @@ include CONTRIBUTING.md include codecov.yml include tox.ini -recursive-include docs *.md *.svg +recursive-include docs *.md *.txt *.rst conf.py Makefile make.bat *.svg graft tests +prune docs/_build prune bin global-exclude *.py[co] __pycache__ diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..41c270b --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) \ No newline at end of file diff --git a/docs/code_examples/flask_server.py b/docs/code_examples/flask_server.py new file mode 100644 index 0000000..84c5da4 --- /dev/null +++ b/docs/code_examples/flask_server.py @@ -0,0 +1,22 @@ +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() \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..766d1f9 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,77 @@ +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +# import os +# import sys +# sys.path.insert(0, os.path.abspath('.')) + + +# -- Project information ----------------------------------------------------- + +project = 'graphql-server 3' +copyright = '2020, graphql-python.org' +author = 'graphql-python.org' + +# The full version, including alpha/beta/rc tags +release = '3.0.0' + + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx_rtd_theme' +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = False + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'sphinx_rtd_theme' + +# Output file base name for HTML help builder. +htmlhelp_basename = 'graphql-server-3-doc' + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +# html_static_path = ['_static'] + +# -- AutoDoc configuration ------------------------------------------------- +# autoclass_content = "both" +autodoc_default_options = { + 'members': True, + 'inherited-members': False, + 'special-members': '__init__', + 'undoc-members': False, + 'show-inheritance': False +} +autosummary_generate = True \ No newline at end of file diff --git a/docs/flask.md b/docs/flask.md deleted file mode 100644 index 80bab4f..0000000 --- a/docs/flask.md +++ /dev/null @@ -1,81 +0,0 @@ -# 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. - -### Special Note for Graphene v3 - -If you are using the `Schema` type of [Graphene](https://github.com/graphql-python/graphene) library, be sure to use the `graphql_schema` attribute to pass as schema on the `GraphQLView` view. Otherwise, the `GraphQLSchema` from `graphql-core` is the way to go. - -More info at [Graphene v3 release notes](https://github.com/graphql-python/graphene/wiki/v3-release-notes#graphene-schema-no-longer-subclasses-graphqlschema-type) and [GraphQL-core 3 usage](https://github.com/graphql-python/graphql-core#usage). - - -### Supported options for GraphQLView - - * `schema`: The `GraphQLSchema` object that you want the view to execute when it gets a valid request. - * `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 **"1.0.3"**. - * `graphiql_template`: Inject a Jinja template string to customize GraphiQL. - * `graphiql_html_title`: The graphiql title to display. Defaults to **"GraphiQL"**. - * `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/). - * `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) \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..65f6e0f --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,25 @@ +Welcome to GraphQL-Server documentation! +======================================== + +.. warning:: + + Please note that the following documentation describes the current version which is currently only available + as a pre-release and needs to be installed with "`--pre`" + +Contents +-------- + +.. toctree:: + :maxdepth: 2 + + intro + usage/index + modules/graphql-server + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` \ No newline at end of file diff --git a/docs/intro.rst b/docs/intro.rst new file mode 100644 index 0000000..da5209a --- /dev/null +++ b/docs/intro.rst @@ -0,0 +1,62 @@ +Introduction +============ + +`GraphQL-Server`_ is a base library that serves as a helper for building GraphQL +servers or integrations into existing web frameworks using `GraphQL-Core-3`_. + +The package also provides some built-in server integrations: + +- Flask +- WebOb +- Sanic +- AIOHTTP + +Any other existing server frameworks can be implemented by using the public +helper functions provided on this package. + + +Getting started +--------------- + +You can install GraphQL-Server using pip_:: + + pip install --pre graphql-server + +You can also install GraphQL-Server with pipenv_, if you prefer that:: + + pipenv install --pre graphql-server + +.. warning:: + + Please note that the following documentation describes the current version + which is currently only available as a pre-release and needs to be installed + with "`--pre`". + + Also note that the conda-forge package is not available as the current setup + for pre / rc releases is not well documented, check this `conda-forge`_ + issue to know more. However you can still use pip inside conda to install + the prerelease version. + +Now you can start using GraphQL-Server by importing from the top-level +:mod:`graphql-server` package. Nearly everything defined in the sub-packages +can also be imported directly from the top-level package. + +.. currentmodule:: graphql_server + +Using the public helper functions, you can define a GraphQLView class on your +server and start adding the graphiql options along with parsing, validation and +execution functions related to graphql. + + +Reporting Issues and Contributing +--------------------------------- + +Please visit the `GitHub repository of GraphQL-Server`_ if you're interested +in the current development or want to report issues or send pull requests. + +.. _GraphQL-Core-3: https://github.com/graphql-python/graphql-core +.. _GraphQL-Server: https://github.com/graphql-python/graphql-server +.. _GitHub repository of GraphQL-Server: https://github.com/graphql-python/graphql-server +.. _pip: https://pip.pypa.io/ +.. _pipenv: https://github.com/pypa/pipenv +.. _conda-forge: https://github.com/conda-forge/python-feedstock/issues/270 \ No newline at end of file diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..5394189 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd \ No newline at end of file diff --git a/docs/modules/aiohttp.rst b/docs/modules/aiohttp.rst new file mode 100644 index 0000000..92e32b9 --- /dev/null +++ b/docs/modules/aiohttp.rst @@ -0,0 +1,6 @@ +AIOHTTP +======= + +.. currentmodule:: graphql_server.aiohttp + +.. automodule:: graphql_server.aiohttp diff --git a/docs/modules/flask.rst b/docs/modules/flask.rst new file mode 100644 index 0000000..f20b744 --- /dev/null +++ b/docs/modules/flask.rst @@ -0,0 +1,6 @@ +FLASK +===== + +.. currentmodule:: graphql_server.flask + +.. automodule:: graphql_server.flask diff --git a/docs/modules/graphql-server.rst b/docs/modules/graphql-server.rst new file mode 100644 index 0000000..20f5330 --- /dev/null +++ b/docs/modules/graphql-server.rst @@ -0,0 +1,33 @@ +Reference +========= + +.. currentmodule:: graphql_server + +.. autofunction:: run_http_query +.. autofunction:: encode_execution_results +.. autofunction:: format_execution_result +.. autofunction:: json_encode +.. autofunction:: load_json_body + +.. _data-structures: + +Data Structures +--------------- + +.. autoclass:: GraphQLParams +.. autoclass:: GraphQLResponse +.. autoclass:: ServerResponse +.. autoclass:: HttpQueryError + +.. _sub-packages: + +Sub-Packages +------------ + +.. toctree:: + :maxdepth: 1 + + aiohttp + flask + sanic + webob \ No newline at end of file diff --git a/docs/modules/sanic.rst b/docs/modules/sanic.rst new file mode 100644 index 0000000..6a86ce8 --- /dev/null +++ b/docs/modules/sanic.rst @@ -0,0 +1,6 @@ +SANIC +===== + +.. currentmodule:: graphql_server.sanic + +.. automodule:: graphql_server.sanic diff --git a/docs/modules/webob.rst b/docs/modules/webob.rst new file mode 100644 index 0000000..55f8018 --- /dev/null +++ b/docs/modules/webob.rst @@ -0,0 +1,6 @@ +WEBOB +===== + +.. currentmodule:: graphql_server.webob + +.. automodule:: graphql_server.webob diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..6443175 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,2 @@ +sphinx>=3.0.0,<4 +sphinx_rtd_theme>=0.4,<1 \ No newline at end of file diff --git a/docs/usage/aiohttp.rst b/docs/usage/aiohttp.rst new file mode 100644 index 0000000..c015d14 --- /dev/null +++ b/docs/usage/aiohttp.rst @@ -0,0 +1,4 @@ +.. _aiohttp: + +aiohttp +------- \ No newline at end of file diff --git a/docs/usage/flask.rst b/docs/usage/flask.rst new file mode 100644 index 0000000..fb802a8 --- /dev/null +++ b/docs/usage/flask.rst @@ -0,0 +1,78 @@ +.. _flask: + +Flask +----- + +In order to add GraphQL support to your Flask application, run the below command +on your terminal:: + + pip install graphql-server[flask] + +Now you must use the *GraphQLView* view from *graphql_server.flask* in order to +add the ``/graphql`` endpoint to your app and enable the GraphiQL IDE. + +Example: + +.. literalinclude:: ../code_examples/flask_server.py + + +.. note:: + If you are using the `Schema` type of `Graphene`_ library, be sure to use + the *graphql_schema* attribute from the schema instance to pass as schema on + the *GraphQLView* view. Otherwise, the *GraphQLSchema* from *graphql-core* + is the way to go. + + More info at `Graphene v3 release notes`_ and `GraphQL-core 3 usage`_. + + +Supported options for GraphQLView +================================= + +The GraphiQL itself supports several options based on the official +implementation: + + * **schema**: The ``GraphQLSchema`` object that you want the view to execute when it gets a valid request. + * **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`_ when loaded directly from the browser + (a useful tool for debugging and exploration). + * **graphiql_version**: The graphiql version to load. Defaults to **"1.0.3"**. + * **graphiql_template**: Inject a Jinja template string to customize GraphiQL. + * **graphiql_html_title**: The graphiql title to display. Defaults to **"GraphiQL"**. + * **batch**: Set the GraphQL view as batch (for using in `Apollo-Client`_ or `ReactRelayNetworkLayer`_) + * **middleware**: A list of graphql `middlewares`_. + * **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. + +.. code-block:: python + + class UserRootValue(GraphQLView): + def get_root_value(self, request): + return request.user + + + +.. _Graphene: https://github.com/graphql-python/graphene +.. _Graphene v3 release notes: https://github.com/graphql-python/graphene/wiki/v3-release-notes#graphene-schema-no-longer-subclasses-graphqlschema-type +.. _GraphQL-core 3 usage: https://github.com/graphql-python/graphql-core#usage +.. _GraphiQL: https://github.com/graphql/graphiql +.. _ReactRelayNetworkLayer: https://github.com/nodkz/react-relay-network-layer +.. _Apollo-Client: http://dev.apollodata.com/core/network.html#query-batching +.. _middlewares: http://docs.graphene-python.org/en/latest/execution/middleware/ +.. _graphql-ws: https://github.com/graphql-python/graphql-ws \ No newline at end of file diff --git a/docs/usage/index.rst b/docs/usage/index.rst new file mode 100644 index 0000000..4893bc1 --- /dev/null +++ b/docs/usage/index.rst @@ -0,0 +1,13 @@ +Usage +===== + +GraphQL-Server provides the capability of integrating Graphql-Core features into +any server integration. + +.. toctree:: + :maxdepth: 2 + + aiohttp + flask + sanic + webob diff --git a/docs/usage/sanic.rst b/docs/usage/sanic.rst new file mode 100644 index 0000000..711370b --- /dev/null +++ b/docs/usage/sanic.rst @@ -0,0 +1,4 @@ +.. _sanic: + +Sanic +----- \ No newline at end of file diff --git a/docs/usage/webob.rst b/docs/usage/webob.rst new file mode 100644 index 0000000..0c3452e --- /dev/null +++ b/docs/usage/webob.rst @@ -0,0 +1,4 @@ +.. _webob: + +Webob +----- \ No newline at end of file diff --git a/graphql_server/__init__.py b/graphql_server/__init__.py index 8942332..fc46bb2 100644 --- a/graphql_server/__init__.py +++ b/graphql_server/__init__.py @@ -2,25 +2,27 @@ GraphQL-Server =================== -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). +GraphQL-Server is a base library that serves as a helper for building GraphQL +servers or integrations into existing web frameworks using +`GraphQL-Core-3`_ + +.. _GraphQL-Core-3: 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 -from graphql.error import GraphQLError from graphql.error import format_error as format_error_default -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 .graphql_server import ( + GraphQLParams, + GraphQLResponse, + ServerResponse, + encode_execution_results, + format_execution_result, + json_encode, + json_encode_pretty, + load_json_body, + run_http_query, +) from .version import version, version_info # The GraphQL-Server 3 version info. @@ -43,287 +45,3 @@ "format_execution_result", "format_error_default", ] - - -# The public data structures - -GraphQLParams = namedtuple("GraphQLParams", "query variables operation_name") -GraphQLResponse = namedtuple("GraphQLResponse", "results params") -ServerResponse = namedtuple("ServerResponse", "body status_code") - - -# The public helper functions - - -def run_http_query( - 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), - the request_method (that must be either "get" or "post"), - the data from the HTTP request body, and the data from the query string. - By default, only one parameter set is expected, but if you set batch_enabled, - you can pass data that contains a list of parameter sets to run multiple - queries as a batch execution using a single HTTP request. You can specify - whether results returning HTTPQueryErrors should be caught and skipped. - All other keyword arguments are passed on to the GraphQL-Core function for - executing GraphQL queries. - - Returns a ServerResults tuple with the list of ExecutionResults as first item - and the list of parameters that have been used for execution as second item. - """ - if not isinstance(schema, GraphQLSchema): - raise TypeError(f"Expected a GraphQL schema, but received {schema!r}.") - if request_method not in ("get", "post"): - raise HttpQueryError( - 405, - "GraphQL only supports GET and POST requests.", - headers={"Allow": "GET, POST"}, - ) - if catch: - catch_exc: Union[Type[HttpQueryError], Type[_NoException]] = HttpQueryError - else: - catch_exc = _NoException - is_batch = isinstance(data, list) - - is_get_request = request_method == "get" - allow_only_query = is_get_request - - if not is_batch: - if not isinstance(data, (dict, MutableMapping)): - raise HttpQueryError( - 400, f"GraphQL params should be a dict. Received {data!r}." - ) - data = [data] - elif not batch_enabled: - raise HttpQueryError(400, "Batch GraphQL requests are not enabled.") - - if not data: - raise HttpQueryError(400, "Received an empty list in the batch request.") - - 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: 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) - - -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=(",", ": ")) - - -def json_encode_pretty(data: Union[Dict, List]) -> str: - return json_encode(data, True) - - -def encode_execution_results( - 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() - and serializes them using JSON to produce an HTTP response. - If you set is_batch=True, then all ExecutionResults will be returned, otherwise only - the first one will be used. You can also pass a custom function that formats the - errors in the ExecutionResults, expecting a dictionary as result and another custom - function that is used to serialize the output. - - Returns a ServerResponse tuple with the serialized response as the first item and - 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) - for execution_result in execution_results - ] - result, status_codes = zip(*results) - status_code = max(status_codes) - - if not is_batch: - result = result[0] - - return ServerResponse(encode(result), status_code) - - -def load_json_body(data): - # type: (str) -> Union[Dict, List] - """Load the request body as a dictionary or a list. - - The body must be passed in a string and will be deserialized from JSON, - raising an HttpQueryError in case of invalid JSON. - """ - try: - return json.loads(data) - except Exception: - raise HttpQueryError(400, "POST body sent invalid JSON.") - - -# Some more private helpers - -FormattedResult = namedtuple("FormattedResult", "result status_code") - - -class _NoException(Exception): - """Private exception used when we don't want to catch any real exception.""" - - -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. - Params from the request body will take precedence over those from the query string. - - You will get a RequestParams tuple with these parameters in return. - """ - query = data.get("query") or query_data.get("query") - variables = data.get("variables") or query_data.get("variables") - # document_id = data.get('documentId') - operation_name = data.get("operationName") or query_data.get("operationName") - - return GraphQLParams(query, load_json_variables(variables), operation_name) - - -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, str): - try: - return json.loads(variables) - except Exception: - raise HttpQueryError(400, "Variables are invalid JSON.") - return variables # type: ignore - - -def assume_not_awaitable(_value: Any) -> bool: - """Replacement for isawaitable if everything is assumed to be synchronous.""" - return False - - -def get_response( - 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 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. - """ - # noinspection PyBroadException - try: - if not params.query: - raise HttpQueryError(400, "Must provide query string.") - - 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 - - return execution_result - - -def format_execution_result( - 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 - - if execution_result: - 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) diff --git a/graphql_server/graphql_server.py b/graphql_server/graphql_server.py new file mode 100644 index 0000000..068c8f9 --- /dev/null +++ b/graphql_server/graphql_server.py @@ -0,0 +1,333 @@ +import json +from collections.abc import MutableMapping +from typing import ( + Any, + Callable, + Collection, + Dict, + List, + NamedTuple, + Optional, + Type, + Union, +) + +from graphql.error import GraphQLError +from graphql.error import format_error as format_error_default +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 graphql_server import HttpQueryError + +# The public data structures + + +class GraphQLParams(NamedTuple): + """GraphQL params of the data.""" + query: Optional[Any] + variables: Optional[Dict] + operation_name: Optional[str] + + +class GraphQLResponse(NamedTuple): + """NamedTuple with the list of ExecutionResults as first item + and the list of parameters that have been used for execution as second item. + """ + results: List[Optional[AwaitableOrValue[ExecutionResult]]] + params: List[GraphQLParams] + + +class ServerResponse(NamedTuple): + """NamedTuple with the serialized response as the first item and + a status code of 200 or 400 in case any result was invalid as the second item. + """ + body: str + status_code: int + + +# The public helper functions + + +def run_http_query( + 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), + the request_method (that must be either "get" or "post"), + the data from the HTTP request body, and the data from the query string. + By default, only one parameter set is expected, but if you set batch_enabled, + you can pass data that contains a list of parameter sets to run multiple + queries as a batch execution using a single HTTP request. You can specify + whether results returning HTTPQueryErrors should be caught and skipped. + All other keyword arguments are passed on to the GraphQL-Core function for + executing GraphQL queries. + + Returns a ServerResults tuple with the list of ExecutionResults as first item + and the list of parameters that have been used for execution as second item. + """ + if not isinstance(schema, GraphQLSchema): + raise TypeError(f"Expected a GraphQL schema, but received {schema!r}.") + if request_method not in ("get", "post"): + raise HttpQueryError( + 405, + "GraphQL only supports GET and POST requests.", + headers={"Allow": "GET, POST"}, + ) + if catch: + catch_exc: Union[Type[HttpQueryError], Type[_NoException]] = HttpQueryError + else: + catch_exc = _NoException + is_batch = isinstance(data, list) + + is_get_request = request_method == "get" + allow_only_query = is_get_request + + if not is_batch: + if not isinstance(data, (dict, MutableMapping)): + raise HttpQueryError( + 400, f"GraphQL params should be a dict. Received {data!r}." + ) + data = [data] + elif not batch_enabled: + raise HttpQueryError(400, "Batch GraphQL requests are not enabled.") + + if not data: + raise HttpQueryError(400, "Received an empty list in the batch request.") + + 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: 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) + + +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=(",", ": ")) + + +def json_encode_pretty(data: Union[Dict, List]) -> str: + return json_encode(data, True) + + +def encode_execution_results( + 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() + and serializes them using JSON to produce an HTTP response. + If you set is_batch=True, then all ExecutionResults will be returned, otherwise only + the first one will be used. You can also pass a custom function that formats the + errors in the ExecutionResults, expecting a dictionary as result and another custom + function that is used to serialize the output. + + Returns a ServerResponse tuple with the serialized response as the first item and + 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) + for execution_result in execution_results + ] + result, status_codes = zip(*results) + status_code = max(status_codes) + + if not is_batch: + result = result[0] + + return ServerResponse(body=encode(result), status_code=status_code) + + +def load_json_body(data): + # type: (str) -> Union[Dict, List] + """Load the request body as a dictionary or a list. + + The body must be passed in a string and will be deserialized from JSON, + raising an HttpQueryError in case of invalid JSON. + """ + try: + return json.loads(data) + except Exception: + raise HttpQueryError(400, "POST body sent invalid JSON.") + + +# Some more private helpers + + +class FormattedResult(NamedTuple): + result: Optional[Dict[str, Any]] + status_code: int + + +class _NoException(Exception): + """Private exception used when we don't want to catch any real exception.""" + + +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. + Params from the request body will take precedence over those from the query string. + + You will get a RequestParams tuple with these parameters in return. + """ + query = data.get("query") or query_data.get("query") + variables = data.get("variables") or query_data.get("variables") + # document_id = data.get('documentId') + operation_name = data.get("operationName") or query_data.get("operationName") + + return GraphQLParams( + query=query, + variables=load_json_variables(variables), + operation_name=operation_name, + ) + + +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, str): + try: + return json.loads(variables) + except Exception: + raise HttpQueryError(400, "Variables are invalid JSON.") + return variables # type: ignore + + +def assume_not_awaitable(_value: Any) -> bool: + """Replacement for isawaitable if everything is assumed to be synchronous.""" + return False + + +def get_response( + 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 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. + """ + # noinspection PyBroadException + try: + if not params.query: + raise HttpQueryError(400, "Must provide query string.") + + 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 + + return execution_result + + +def format_execution_result( + 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 + + if execution_result: + 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) diff --git a/setup.py b/setup.py index ea5ea65..921dd8e 100644 --- a/setup.py +++ b/setup.py @@ -20,6 +20,8 @@ "black==19.10b0", "mypy>=0.761,<0.770", "check-manifest>=0.40,<1", + "sphinx>=3.0.0,<4", + "sphinx_rtd_theme>=0.4,<1" ] + tests_requires install_flask_requires = [ diff --git a/tox.ini b/tox.ini index 813c610..ea87b80 100644 --- a/tox.ini +++ b/tox.ini @@ -52,3 +52,9 @@ basepython = python3.8 deps = -e.[dev] commands = check-manifest -v + +[testenv:docs] +basepython = python3.8 +deps = -e.[dev] +commands = + sphinx-build -b html -EW docs docs/_build/html \ No newline at end of file