From e7b4b046b2a576eb39a1e4fe11bdd1493028a01b Mon Sep 17 00:00:00 2001 From: Christoph Zwerschke Date: Sat, 6 Apr 2019 00:22:14 +0200 Subject: [PATCH] Improve README and add docstrings Separate between official and internal helper functions. Only the official functions are listed in __all__, and the internal ones are moved to the bottom of the file. Also, added a script to create docs from the docstrings. Add Python 3.6 and 3.7 as supported Python versions. --- .gitignore | 16 ++- .travis.yml | 5 +- README.md | 44 ++++++-- README.rst | 63 ++++++++--- bin/build_docs | 6 + bin/convert_documentation | 3 - bin/convert_readme | 6 + graphql_server/__init__.py | 226 ++++++++++++++++++++++++------------- graphql_server/error.py | 20 +++- setup.cfg | 2 +- setup.py | 2 + tox.ini | 10 +- 12 files changed, 279 insertions(+), 124 deletions(-) create mode 100644 bin/build_docs delete mode 100755 bin/convert_documentation create mode 100755 bin/convert_readme diff --git a/.gitignore b/.gitignore index 5085c72..05a5a88 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,16 @@ *.pyc -.idea -.cache -.tox +*.pyo + *.egg *.egg-info + +.cache .coverage -/build/ +.idea +.mypy_cache +.pytest_cache +.tox +.venv +/build/ /dist/ -/.mypy_cache -/.pytest_cache diff --git a/.travis.yml b/.travis.yml index 2d3731b..1053da9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,7 +11,10 @@ matrix: - python: '3.5' env: TOX_ENV=py35 - python: '3.6' - env: TOX_ENV=py36,import-order,flake8,mypy + env: TOX_ENV=py36 + - python: '3.7' + env: TOX_ENV=py37,import-order,flake8,mypy + dist: xenial cache: directories: - $HOME/.cache/pip diff --git a/README.md b/README.md index 4b410f1..4c86a5e 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,40 @@ -# GraphQL-Server +# 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://coveralls.io/repos/graphql-python/graphql-server-core/badge.svg?branch=master&service=github)](https://coveralls.io/github/graphql-python/graphql-server-core?branch=master) [![PyPI version](https://badge.fury.io/py/graphql-server-core.svg)](https://badge.fury.io/py/graphql-server-core) -GraphQL Server core package. +GraphQL-Server-Core 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). -## Integrations +## Existing integrations built with GraphQL-Server-Core -GraphQL Server powers the following integrations +| 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 | Package | -|---------------|-------------------| -| Django | [graphene-django](https://github.com/graphql-python/graphene-django/) | -| Flask | [flask-graphql](https://github.com/graphql-python/flask-graphql/) | -| Sanic | [sanic-graphql](https://github.com/graphql-python/sanic-graphql/) | -| WebOb (Pyramid, Pylons) | [webob-graphql](https://github.com/graphql-python/webob-graphql/) | +## Other integrations using GraphQL-Core or Graphene + +| Server integration | Package | +|---|---| +| Django | [graphene-django](https://github.com/graphql-python/graphene-django/) | + +## Documentation + +The `graphql_server` package provides these three public helper functions: + + * `run_http_query` + * `encode_execution_results` + * `laod_json_body` + +All functions in the package are annotated with type hints and docstrings, +and you can build HTML documentation from these using `bin/build_docs`. + +You can also use one of the existing integrations listed above as +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. diff --git a/README.rst b/README.rst index bbed81f..a9afadf 100644 --- a/README.rst +++ b/README.rst @@ -1,26 +1,55 @@ -GraphQL-Server -============== +GraphQL-Server-Core +=================== |Build Status| |Coverage Status| |PyPI version| -GraphQL Server core package. +GraphQL-Server-Core is a base library that serves as a helper for +building GraphQL servers or integrations into existing web frameworks +using `GraphQL-Core `__. -Integrations ------------- +Existing integrations built with GraphQL-Server-Core +---------------------------------------------------- -GraphQL Server powers the following integrations +=========================== ========================================================================================================== +Server integration Package +=========================== ========================================================================================================== +Flask `flask-graphql `__ +Sanic `sanic-graphql `__ +AIOHTTP `aiohttp-graphql `__ +WebOb (Pyramid, TurboGears) `webob-graphql `__ +WSGI `wsgi-graphql `__ +Responder `responder.ext.graphql `__ +=========================== ========================================================================================================== -+---------------------------+----------------------------------------------------------------------------+ -| Server integration | Package | -+===========================+============================================================================+ -| Django | `graphene-django `__ | -+---------------------------+----------------------------------------------------------------------------+ -| Flask | `flask-graphql `__ | -+---------------------------+----------------------------------------------------------------------------+ -| Sanic | `sanic-graphql `__ | -+---------------------------+----------------------------------------------------------------------------+ -| WebOb (Pyramid, Pylons) | `webob-graphql `__ | -+---------------------------+----------------------------------------------------------------------------+ +Other integrations using GraphQL-Core or Graphene +------------------------------------------------- + +================== ======================================================================== +Server integration Package +================== ======================================================================== +Django `graphene-django `__ +================== ======================================================================== + +Documentation +------------- + +The ``graphql_server`` package provides these three public helper +functions: + +- ``run_http_query`` +- ``encode_execution_results`` +- ``laod_json_body`` + +All functions in the package are annotated with type hints and +docstrings, and you can build HTML documentation from these using +``bin/build_docs``. + +You can also use one of the existing integrations listed above as +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. .. |Build Status| image:: https://travis-ci.org/graphql-python/graphql-server-core.svg?branch=master :target: https://travis-ci.org/graphql-python/graphql-server-core diff --git a/bin/build_docs b/bin/build_docs new file mode 100644 index 0000000..cdc7f8b --- /dev/null +++ b/bin/build_docs @@ -0,0 +1,6 @@ +#!/bin/bash + +# the documentation can be created from the docstrings +# with pdoc (https://pdoc3.github.io/pdoc/) + +pdoc --html --overwrite --html-dir docs graphql_server diff --git a/bin/convert_documentation b/bin/convert_documentation deleted file mode 100755 index b55d5da..0000000 --- a/bin/convert_documentation +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash - -pandoc README.md --from markdown --to rst -s -o README.rst diff --git a/bin/convert_readme b/bin/convert_readme new file mode 100755 index 0000000..3e21799 --- /dev/null +++ b/bin/convert_readme @@ -0,0 +1,6 @@ +#!/bin/bash + +# the README can be converted from MarkDown to reST +# with pandoc (https://pandoc.org/) + +pandoc README.md --from markdown --to rst -s -o README.rst diff --git a/graphql_server/__init__.py b/graphql_server/__init__.py index 58b2f7e..e27285b 100644 --- a/graphql_server/__init__.py +++ b/graphql_server/__init__.py @@ -1,5 +1,15 @@ +""" +GraphQL-Server-Core +=================== + +GraphQL-Server-Core 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, MutableMapping +from collections import MutableMapping, namedtuple import six from graphql import get_default_backend @@ -14,12 +24,14 @@ from graphql import GraphQLSchema, GraphQLBackend -class SkipException(Exception): - pass +__all__ = [ + "run_http_query", + "encode_execution_results", + "load_json_body", + "HttpQueryError"] -GraphQLParams = namedtuple("GraphQLParams", "query,variables,operation_name") -GraphQLResponse = namedtuple("GraphQLResponse", "result,status_code") +# The public helper functions def run_http_query( @@ -31,6 +43,21 @@ def run_http_query( catch=False, # type: bool **execute_options # type: Dict ): + """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. + + This functions returns a tuple with the list of ExecutionResults as first item + and the list of parameters that have been used for execution as second item. + """ if request_method not in ("get", "post"): raise HttpQueryError( 405, @@ -40,9 +67,9 @@ def run_http_query( if catch: catch_exc = ( HttpQueryError - ) # type: Union[Type[HttpQueryError], Type[SkipException]] + ) # type: Union[Type[HttpQueryError], Type[_NoException]] else: - catch_exc = SkipException + catch_exc = _NoException is_batch = isinstance(data, list) is_get_request = request_method == "get" @@ -77,13 +104,22 @@ def run_http_query( def encode_execution_results( execution_results, # type: List[Optional[ExecutionResult]] - format_error, # type: Callable[[Exception], Dict] - is_batch, # type: bool - encode, # type: Callable[[Dict], Any] + format_error=None, # type: Callable[[Exception], Dict] + is_batch=False, # type: bool + encode=None, # type: Callable[[Dict], Any] ): # type: (...) -> Tuple[Any, int] + """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. + """ responses = [ - format_execution_result(execution_result, format_error) + format_execution_result(execution_result, format_error or default_format_error) for execution_result in execution_results ] result, status_codes = zip(*responses) @@ -92,29 +128,42 @@ def encode_execution_results( if not is_batch: result = result[0] - return encode(result), status_code + return (encode or json_encode)(result), status_code -def json_encode(data, pretty=False): - # type: (Dict, bool) -> str - if not pretty: - return json.dumps(data, separators=(",", ":")) +def load_json_body(data): + # type: (str) -> Union[Dict, List] + """Load the request body as a dictionary or a list. - return json.dumps(data, indent=2, separators=(",", ": ")) + 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.") -def load_json_variables(variables): - # type: (Optional[Union[str, Dict]]) -> Optional[Dict] - if variables and isinstance(variables, six.string_types): - try: - return json.loads(variables) - except Exception: - raise HttpQueryError(400, "Variables are invalid JSON.") - return variables # type: ignore +# Some more private helpers + +GraphQLParams = namedtuple("GraphQLParams", "query variables operation_name") + +GraphQLResponse = namedtuple("GraphQLResponse", "result status_code") + + +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) -> 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 GraphQLParams object with these parameters as attributes 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') @@ -123,39 +172,20 @@ def get_graphql_params(data, query_data): return GraphQLParams(query, load_json_variables(variables), operation_name) -def get_response( - schema, # type: GraphQLSchema - params, # type: GraphQLParams - catch, # type: Type[BaseException] - allow_only_query=False, # type: bool - **kwargs # type: Dict -): - # type: (...) -> Optional[ExecutionResult] - try: - execution_result = execute_graphql_request( - schema, params, allow_only_query, **kwargs - ) - except catch: - return None - - return execution_result - - -def format_execution_result( - execution_result, # type: Optional[ExecutionResult] - format_error, # type: Optional[Callable[[Exception], Dict]] -): - # type: (...) -> GraphQLResponse - status_code = 200 - - if execution_result: - if execution_result.invalid: - status_code = 400 - response = execution_result.to_dict(format_error=format_error) - else: - response = None +def load_json_variables(variables): + # type: (Optional[Union[str, Dict]]) -> Optional[Dict] + """Return the given GraphQL variables as a dictionary. - return GraphQLResponse(response, status_code) + 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): + try: + return json.loads(variables) + except Exception: + raise HttpQueryError(400, "Variables are invalid JSON.") + return variables # type: ignore def execute_graphql_request( @@ -165,6 +195,15 @@ def execute_graphql_request( backend=None, # type: GraphQLBackend **kwargs # type: Dict ): + """Execute a GraphQL request and return a 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.") @@ -194,25 +233,60 @@ def execute_graphql_request( return ExecutionResult(errors=[e], invalid=True) -def load_json_body(data): - # type: (str) -> Dict +def get_response( + schema, # type: GraphQLSchema + params, # type: GraphQLParams + catch_exc, # type: Type[BaseException] + allow_only_query=False, # type: bool + **kwargs # type: Dict +): + # type: (...) -> Optional[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. + """ + try: - return json.loads(data) - except Exception: - raise HttpQueryError(400, "POST body sent invalid JSON.") + execution_result = execute_graphql_request( + schema, params, allow_only_query, **kwargs + ) + except catch_exc: + return None + return execution_result -__all__ = [ - "HttpQueryError", - "default_format_error", - "SkipException", - "run_http_query", - "encode_execution_results", - "json_encode", - "load_json_variables", - "get_graphql_params", - "get_response", - "format_execution_result", - "execute_graphql_request", - "load_json_body", -] + +def format_execution_result( + execution_result, # type: Optional[ExecutionResult] + format_error, # type: Optional[Callable[[Exception], Dict]] +): + # type: (...) -> GraphQLResponse + """Format an execution result into a GraphQLResponse. + + This converts the given execution result into a GraphQLResponse that contains + the ExecutionResult converted to a dictionary and an appropriate status code. + """ + status_code = 200 + + if execution_result: + if execution_result.invalid: + status_code = 400 + response = execution_result.to_dict(format_error=format_error) + else: + response = None + + return GraphQLResponse(response, status_code) + + +def json_encode(data, pretty=False): + # type: (Union[Dict,List], bool) -> str + """Serialize the given data 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=(",", ": ")) diff --git a/graphql_server/error.py b/graphql_server/error.py index 5de515f..b0ca74a 100644 --- a/graphql_server/error.py +++ b/graphql_server/error.py @@ -1,5 +1,17 @@ +"""Error classes provided by graphql_server""" + +__all__ = ["HttpQueryError"] + + class HttpQueryError(Exception): + """The error class for HTTP errors produced by running GraphQL queries.""" + def __init__(self, status_code, message=None, is_graphql_error=False, headers=None): + """Create a HTTP query error. + + You need to pass the HTTP status code, the message that shall be shown, + whether this is a GraphQL error, and the HTTP headers that shall be sent. + """ self.status_code = status_code self.message = message self.is_graphql_error = is_graphql_error @@ -7,6 +19,7 @@ def __init__(self, status_code, message=None, is_graphql_error=False, headers=No super(HttpQueryError, self).__init__(message) def __eq__(self, other): + """Check whether this HTTP query error is equal to another one.""" return ( isinstance(other, HttpQueryError) and other.status_code == self.status_code @@ -15,9 +28,6 @@ def __eq__(self, other): ) def __hash__(self): - if self.headers: - headers_hash = tuple(self.headers.items()) - else: - headers_hash = None - + """Create a hash value for this HTTP query error.""" + headers_hash = tuple(self.headers.items()) if self.headers else None return hash((self.status_code, self.message, headers_hash)) diff --git a/setup.cfg b/setup.cfg index 72574d8..8de4f2d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -5,7 +5,7 @@ max-line-length = 160 [isort] known_first_party=graphql_server -[pytest] +[tool:pytest] norecursedirs = venv .tox .cache [bdist_wheel] diff --git a/setup.py b/setup.py index 537f58e..2ca3baa 100644 --- a/setup.py +++ b/setup.py @@ -22,6 +22,8 @@ "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", "Programming Language :: Python :: Implementation :: PyPy", "License :: OSI Approved :: MIT License", ], diff --git a/tox.ini b/tox.ini index 5c220bc..0de7495 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = flake8,import-order,py35,py27,py33,py34,pypy +envlist = flake8,import-order,py37,py36,py35,py34,py33,py27,pypy skipsdist = true [testenv] @@ -10,22 +10,22 @@ deps = graphql-core>=2.1 pytest-cov commands = - py{py,27,34,35,36}: py.test tests {posargs} + py{py,27,33,34,35,36,37}: py.test tests {posargs} [testenv:flake8] -basepython=python3.6 +basepython=python3.7 deps = flake8 commands = flake8 graphql_server [testenv:mypy] -basepython=python3.6 +basepython=python3.7 deps = mypy commands = mypy graphql_server --ignore-missing-imports [testenv:import-order] -basepython=python3.6 +basepython=python3.7 deps = isort graphql-core>=2.1