From 1f9d8dde0d36266e285ba32d4baecbb835ebb4f3 Mon Sep 17 00:00:00 2001 From: Philipp Kewisch Date: Sat, 16 Aug 2025 23:38:20 +0300 Subject: [PATCH] add support for httpx endpoints --- README.rst | 18 + examples/basic/04_httpx_endpoint.py | 46 ++ poetry.lock | 135 +++++- pyproject.toml | 5 + sgqlc/endpoint/httpx.py | 253 +++++++++++ tests/test-endpoint-httpx.py | 679 ++++++++++++++++++++++++++++ 6 files changed, 1133 insertions(+), 3 deletions(-) create mode 100755 examples/basic/04_httpx_endpoint.py create mode 100644 sgqlc/endpoint/httpx.py create mode 100644 tests/test-endpoint-httpx.py diff --git a/README.rst b/README.rst index 7476231..6b29780 100644 --- a/README.rst +++ b/README.rst @@ -244,6 +244,24 @@ hand-written query (see more at ``examples/basic/01_http_endpoint.py``): endpoint = HTTPEndpoint(url, headers) data = endpoint(query, variables) +To make an asynchronous query, use the `httpx` library with `HTTPXEndpoint` +(see more at ``examples/basic/04_httpx_endpoint.py``): + +.. code-block:: python + + from sgqlc.endpoint.httpx import HTTPXEndpoint + import httpx + + async def main(): + url = 'http://server.com/graphql' + headers = {'Authorization': 'bearer TOKEN'} + + query = 'query { ... }' + variables = {'varName': 'value'} + + endpoint = HTTPEndpoint(url, headers, client=httpx.AsyncClient()) + data = await endpoint(query, variables) + However, writing GraphQL queries and later interpreting the results may be cumbersome. That's solved by our ``sgqlc.types``, which is diff --git a/examples/basic/04_httpx_endpoint.py b/examples/basic/04_httpx_endpoint.py new file mode 100755 index 0000000..70f6a46 --- /dev/null +++ b/examples/basic/04_httpx_endpoint.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 + +import sys +import json +import httpx +import asyncio +from sgqlc.endpoint.httpx import HTTPXEndpoint + + +async def main(): + try: + token, repo = sys.argv[1:] + except ValueError: + raise SystemExit('Usage: ') + + query = ''' + query GitHubRepoIssues($repoOwner: String!, $repoName: String!) { + repository(owner: $repoOwner, name: $repoName) { + issues(first: 100) { + nodes { + number + title + } + } + } + } + ''' + + owner, name = repo.split('/', 1) + variables = { + 'repoOwner': owner, + 'repoName': name, + } + + url = 'https://api.github.com/graphql' + headers = { + 'Authorization': 'bearer ' + token, + } + + endpoint = HTTPXEndpoint(url, headers, client=httpx.AsyncClient()) + data = await endpoint(query, variables) + + json.dump(data, sys.stdout, sort_keys=True, indent=2, default=str) + + +asyncio.run(main()) diff --git a/poetry.lock b/poetry.lock index 4e005e5..73b6b4f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -12,6 +12,29 @@ files = [ {file = "alabaster-0.7.13.tar.gz", hash = "sha256:a27a4a084d5e690e16e01e03ad2b2e552c61a65469419b907243193de1a84ae2"}, ] +[[package]] +name = "anyio" +version = "4.5.2" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "anyio-4.5.2-py3-none-any.whl", hash = "sha256:c011ee36bc1e8ba40e5a81cb9df91925c218fe9b778554e0b56a21e1b5d4716f"}, + {file = "anyio-4.5.2.tar.gz", hash = "sha256:23009af4ed04ce05991845451e11ef02fc7c5ed29179ac9a420e5ad0ac7ddc5b"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +sniffio = ">=1.1" +typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} + +[package.extras] +doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21.0b1) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\""] +trio = ["trio (>=0.26.1)"] + [[package]] name = "babel" version = "2.17.0" @@ -250,7 +273,7 @@ version = "1.3.0" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" -groups = ["dev"] +groups = ["main", "dev"] markers = "python_version < \"3.11\"" files = [ {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, @@ -278,6 +301,65 @@ files = [ [package.dependencies] typing-extensions = {version = ">=4,<5", markers = "python_version < \"3.10\""} +[[package]] +name = "h11" +version = "0.16.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.16" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + [[package]] name = "idna" version = "3.10" @@ -496,6 +578,25 @@ tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +[[package]] +name = "pytest-asyncio" +version = "0.24.0" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b"}, + {file = "pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276"}, +] + +[package.dependencies] +pytest = ">=8.2,<9" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + [[package]] name = "pytest-cov" version = "5.0.0" @@ -550,6 +651,33 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "respx" +version = "0.22.0" +description = "A utility for mocking out the Python HTTPX and HTTP Core libraries." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0"}, + {file = "respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91"}, +] + +[package.dependencies] +httpx = ">=0.25.0" + +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + [[package]] name = "snowballstemmer" version = "3.0.1" @@ -761,11 +889,11 @@ description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" groups = ["main", "dev"] +markers = "python_version < \"3.11\"" files = [ {file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"}, {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, ] -markers = {main = "python_version < \"3.10\"", dev = "python_version < \"3.11\""} [[package]] name = "urllib3" @@ -824,10 +952,11 @@ test = ["big-O", "importlib-resources ; python_version < \"3.9\"", "jaraco.funct type = ["pytest-mypy"] [extras] +httpx = ["httpx"] requests = ["requests"] websocket = ["websocket-client"] [metadata] lock-version = "2.1" python-versions = ">=3.8,<4" -content-hash = "356bc6957bd507c01a56dd4a9db16ad1fc0077284398343d6f245e32bb807d87" +content-hash = "ec147ad7f07a33b6eb9eec393771c1c15168f617f17f20aa52e6d2e0440bc6d8" diff --git a/pyproject.toml b/pyproject.toml index cfc76ab..318ff75 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ force-exclude = '/(doc/|examples/.*(schema|operations)[.]py)' [tool.pytest.ini_options] addopts = '--import-mode=importlib --doctest-modules --cov=sgqlc --cov-fail-under=100 --cov-report=term-missing --cov-report=xml --cov-report=html:cover' +asyncio_mode = 'auto' python_files = ['test-*.py', 'test_*.py'] testpaths = [ 'sgqlc', @@ -69,14 +70,18 @@ sgqlc-codegen = 'sgqlc.codegen:main' [project.optional-dependencies] websocket = ['websocket-client'] requests = ['requests'] +httpx = ['httpx'] [tool.poetry.group.dev.dependencies] pytest = '*' pytest-cov = '*' +pytest-asyncio = '*' sphinx = '*' sphinx-argparse = '*' websocket-client = '*' requests = '*' +httpx = '*' +respx = '*' [tool.poetry.group.docs.dependencies] sphinx = '*' diff --git a/sgqlc/endpoint/httpx.py b/sgqlc/endpoint/httpx.py new file mode 100644 index 0000000..929d7dc --- /dev/null +++ b/sgqlc/endpoint/httpx.py @@ -0,0 +1,253 @@ +''' +Synchronous or Asynchronous Endpoint using httpx +================================================ + +This endpoint implements GraphQL client using the :mod:`httpx` library. + + +This module provides command line utility: + +.. code-block:: console + + $ python3 -m sgqlc.endpoint.httpx http://server.com/ '{ queryHere { ... } }' + +It's pretty much like :class:`sgqlc.endpoint.http.HTTPEndpoint`, but +using the :mod:`httpx`. This allows you to make asynchronous requests using +:mod:`httpx.AsyncClient` or re-using a global client for better connection +pooling. + +Example using :class:`sgqlc.endpoint.httpx.HTTPXEndpoint`: + +.. literalinclude:: ../../examples/basic/04_httpx_endpoint.py + :language: python + +The ``query`` may be given as ``bytes`` or ``str`` as in the example, but +it may be a :class:`sgqlc.operation.Operation`, which will serialize as +string while also providing convenience to interepret the results. + +See `more examples `_. + +:license: ISC +''' + +__docformat__ = 'reStructuredText en' + +__all__ = ('HTTPXEndpoint',) + +import json +import httpx + +from .base import add_query_to_url +from .http import HTTPEndpoint +from typing import Optional, Union, Dict + + +class HTTPXEndpoint(HTTPEndpoint): + '''GraphQL endpoint access via httpx. + + This helper is very thin, just setups the correct HTTP request to + GraphQL endpoint, handling logging of HTTP and GraphQL errors. The + object is callable with parameters: ``query``, ``variables``, + ``operation_name``, ``extra_headers`` and ``timeout``. + + The user of this class should create GraphQL queries and interpret the + resulting object, created from JSON data, with top level properties: + + :data: object matching the GraphQL requests, or ``null`` if only + errors were returned. + + :headers: dictionary of HTTP response headers. + + :errors: list of errors, which are objects with the key "message" and + optionally others, such as "location" (for errors matching GraphQL + input). Instead of raising exceptions, such as + :exc:`requests.exceptions.HTTPError` or + :exc:`json.JSONDecodeError` those are stored in the + "exception" key. + + :client: The httpx.Client() or httpx.AsyncClient(). If you are using an + AsyncClient, the endpoint call will return a coroutine you can await. + + .. note:: + + Both ``data`` and ``errors`` may be returned, for instance if + a null-able field fails, it will be returned as null (Python + ``None``) in data the associated error in the array. + + The class has its own :class:`logging.Logger` which is used to + debug, info, warning and errors. Error logging and conversion to + uniform data structure similar to GraphQL, with ``{"errors": [...]}`` + is done by :func:`HTTPXEndpoint._log_httpx_error()` own method, + ``BaseEndpoint._log_json_error()`` and + ``BaseEndpoint._log_graphql_error()``. This last one will show the + snippets of GraphQL that failed execution. + + ''' + + def __init__( + self, + *args, + client: Optional[httpx.Client] = None, + **kwargs, + ): + ''' + :param client: The existing httpx.Client to use. + :type client: httpx.Client + ''' + super().__init__(*args, **kwargs) + self.client = client or httpx.Client() + + def __call__( + self, + query: Union[bytes, str], + variables: Optional[Dict] = None, + operation_name: Optional[str] = None, + extra_headers: Optional[Dict[str, str]] = None, + timeout: Optional[int] = None, + ): + '''Calls the GraphQL endpoint. + + :param query: the GraphQL query or mutation to execute. Note + that this is converted using ``bytes()``, thus one may pass + an object implementing ``__bytes__()`` method to return the + query, eventually in more compact form (no indentation, etc). + :type query: :class:`str` or :class:`bytes`. + + :param variables: variables (dict) to use with + ``query``. This is only useful if the query or + mutation contains ``$variableName``. + :type variables: dict + + :param operation_name: if more than one operation is listed in + ``query``, then it should specify the one to be executed. + :type operation_name: str + + :param extra_headers: dict with extra HTTP headers to use. + :type extra_headers: dict + + :param timeout: overrides the default timeout. + :type timeout: float + + :return: dict with optional fields ``data`` containing the GraphQL + returned data as nested dict, ``headers`` with a dictionary of + response headers, and ``errors`` with an array of errors. + Note that both ``data`` and ``errors`` may be returned! + :rtype: dict + + ''' + query, req = self._prepare( + query=query, + variables=variables, + operation_name=operation_name, + extra_headers=extra_headers, + ) + + req.extensions['timeout'] = httpx.Timeout( + timeout or self.timeout + ).as_dict() + + if isinstance(self.client, httpx.AsyncClient): + + async def runner(): + try: + response = await self.client.send(req) + return self._parse_httpx_response(query, response) + except httpx.HTTPError as exc: + return self._log_httpx_error(query, req, exc) + + return runner() + elif isinstance(self.client, httpx.Client): + try: + response = self.client.send(req) + return self._parse_httpx_response(query, response) + except httpx.HTTPError as exc: + return self._log_httpx_error(query, req, exc) + + def _parse_httpx_response(self, query, response): + response.raise_for_status() + + try: + data = response.json() + if data and data.get('errors'): + return self._log_graphql_error(query, data) + return data + except json.JSONDecodeError as exc: + return self._log_json_error(response.text, exc) + + def _log_httpx_error(self, query, request, exc): + self.logger.error('%s: %s', request.url, exc) + + content_type = exc.response.headers.get('Content-Type', '') + body = exc.response.text + if not content_type.startswith('application/json'): + return { + 'data': None, + 'errors': [ + { + 'message': str(exc), + 'exception': exc, + 'status': exc.response.status_code, + 'headers': dict(exc.response.headers), + 'body': body, + } + ], + } + else: + # GraphQL servers return 400 and {'errors': [...]} + # if only errors was returned, no {'data': ...} + + try: + data = exc.response.json() + except json.JSONDecodeError as exc: + return self._log_json_error(body, exc) + + if isinstance(data, dict) and data.get('errors'): + data.update( + { + 'exception': exc, + 'status': exc.response.status_code, + 'headers': dict(exc.response.headers), + } + ) + return self._log_graphql_error(query, data) + + return { + 'data': None, + 'errors': [ + { + 'message': str(exc), + 'exception': exc, + 'status': exc.response.status_code, + 'headers': dict(exc.response.headers), + 'body': body, + } + ], + } + + def get_http_post_request(self, query, variables, operation_name, headers): + '''Createa a http POST request for the query.''' + return self.client.build_request( + method='POST', + url=self.url, + headers=headers, + json={ + 'query': query, + 'variables': variables, + 'operationName': operation_name, + }, + ) + + def get_http_get_request(self, query, variables, operation_name, headers): + '''Create a http GET request for the query.''' + params = {'query': query} + if operation_name: + params['operationName'] = operation_name + + if variables: + params['variables'] = json.dumps(variables) + + url = add_query_to_url(self.url, params) + + return self.client.build_request( + method='GET', url=url, headers=headers + ) diff --git a/tests/test-endpoint-httpx.py b/tests/test-endpoint-httpx.py new file mode 100644 index 0000000..57c6ae2 --- /dev/null +++ b/tests/test-endpoint-httpx.py @@ -0,0 +1,679 @@ +import json + +import httpx + +from sgqlc.endpoint.httpx import HTTPXEndpoint +from sgqlc.types import Schema, Type +from sgqlc.operation import Operation + +test_url = 'http://some-server.com/graphql' + +extra_accept_header = ', '.join( + [ + 'application/json; charset=utf-8', + 'application/vnd.xyz.feature-flag+json', + ] +) + +graphql_query = ''' +query GitHubRepoIssues($repoOwner: String!, $repoName: String!) { + repository(owner: $repoOwner, name: $repoName) { + issues(first: 100) { + nodes { + number + title + } + } + } +} +''' + +graphql_headers_ok = { + 'Content-Type': 'application/json; charset=utf8', + 'X-Ratelimit-Limit': '1000', +} + +graphql_headers_gzip = { + 'Content-Type': 'application/json; charset=utf8', + 'X-Ratelimit-Limit': '1000', + 'Content-Encoding': 'gzip', +} + + +graphql_response_ok = { + 'headers': { + 'Content-Type': 'application/json; charset=utf8', + 'X-Ratelimit-Limit': '1000', + }, + 'data': { + 'repository': { + 'issues': { + 'nodes': [{'number': 1, 'title': 'unit tests: sgqlc.types'}] + } + } + }, +} + +graphql_response_error = { + 'headers': { + 'Content-Type': 'application/json; charset=utf8', + 'X-Ratelimit-Limit': '1000', + }, + 'errors': [ + { + 'message': 'Server Reported Error', + 'locations': [{'line': 1, 'column': 1}], + }, + {'message': 'Other Message', 'path': ['repository', 'issues']}, + ], +} + +graphql_response_json_error = b''' +{ + "data": { +''' + +# -- Test Helpers -- + + +def get_json_exception(s): + try: + json.loads(s) + return None + except json.JSONDecodeError as e: + return e + + +def check_request_headers_(req, headers, name): + if not headers: + return + if isinstance(headers, dict): + headers = headers.items() + for k, v in headers: + g = req.headers[k] + assert g == v, 'Failed {} header {}: {!r} != {!r}'.format( + name, k, v, g + ) + + +def check_request_headers(req, base_headers, extra_headers): + if extra_headers and 'Accept' in extra_headers: + accept_header = extra_accept_header + else: + accept_header = 'application/json; charset=utf-8' + assert req.headers['Accept'] == accept_header + if req.method == 'POST': + assert req.headers['Content-type'] == 'application/json' + check_request_headers_(req, base_headers, 'base') + check_request_headers_(req, extra_headers, 'extra') + + +def check_request_variables(req, variables): + if req.method == 'POST': + post_data = json.loads(req.content) + received = post_data.get('variables') + else: + received = json.loads(req.url.params.get('variables', 'null')) + + assert received == variables + + +def check_request_operation_name(req, operation_name): + if req.method == 'POST': + post_data = json.loads(req.content) + received = post_data.get('operationName') + else: + received = req.url.params.get('operationName') + + assert received == operation_name + + +def check_request_query(req, query): + if req.method == 'POST': + post_data = json.loads(req.content) + received = post_data.get('query') + else: + received = req.url.params.get('query') + + if isinstance(query, bytes): + query = query.decode('utf-8') + + assert received == query + + +def check_respx_route( + route, + timeout=None, + base_headers=None, + extra_headers=None, + variables=None, + operation_name=None, + query=None, # defaults to `graphql_query` +): + assert route.called + req = route.calls.last.request + check_request_headers(req, base_headers, extra_headers) + check_request_variables(req, variables) + check_request_operation_name(req, operation_name) + check_request_query(req, query or graphql_query) + + assert route.calls.last.request.extensions['timeout']['connect'] == timeout + assert route.calls.last.request.extensions['timeout']['read'] == timeout + assert route.calls.last.request.extensions['timeout']['write'] == timeout + assert route.calls.last.request.extensions['timeout']['pool'] == timeout + + +# -- Actual Tests -- + + +def test_basic(respx_mock): + 'Test if basic usage with only essential parameters works' + + route = respx_mock.route(name='graphql', method='POST', url=test_url).mock( + return_value=httpx.Response( + 200, json=graphql_response_ok, headers=graphql_headers_ok + ) + ) + + endpoint = HTTPXEndpoint(test_url) + data = endpoint(graphql_query) + assert data == graphql_response_ok + + check_respx_route(route) + assert str(endpoint) == ( + 'HTTPXEndpoint(url={}, '.format(test_url) + + 'base_headers={}, timeout=None, method=POST)' + ) + + +async def test_basic_async(respx_mock): + 'Test if basic usage with an async client' + + route = respx_mock.route(name='graphql', method='POST', url=test_url).mock( + return_value=httpx.Response( + 200, json=graphql_response_ok, headers=graphql_headers_ok + ) + ) + + endpoint = HTTPXEndpoint(test_url, client=httpx.AsyncClient()) + data = await endpoint(graphql_query) + assert data == graphql_response_ok + check_respx_route(route) + + +def test_basic_bytes_query(respx_mock): + 'Test if query with type bytes works' + + route = respx_mock.route(name='graphql', method='POST', url=test_url).mock( + return_value=httpx.Response( + 200, json=graphql_response_ok, headers=graphql_headers_ok + ) + ) + + endpoint = HTTPXEndpoint(test_url) + data = endpoint(graphql_query.encode('utf-8')) + assert data == graphql_response_ok + check_respx_route(route) + + +def test_basic_operation_query(respx_mock): + 'Test if query with type sgqlc.operation.Operation() works' + + route = respx_mock.route(name='graphql', method='POST', url=test_url).mock( + return_value=httpx.Response( + 200, json=graphql_response_ok, headers=graphql_headers_ok + ) + ) + + schema = Schema() + + # MyType and Query may be declared if doctests were processed by pytest + if 'MyType' in schema: + schema -= schema.MyType + + if 'Query' in schema: + schema -= schema.Query + + class MyType(Type): + __schema__ = schema + i = int + + class Query(Type): + __schema__ = schema + my_type = MyType + + op = Operation(Query) + op.my_type.i() + + endpoint = HTTPXEndpoint(test_url) + data = endpoint(op) + assert data == graphql_response_ok + check_respx_route(route, query=bytes(op)) + + +def test_headers(respx_mock): + 'Test if all headers are passed' + + route = respx_mock.route(name='graphql', method='POST', url=test_url).mock( + return_value=httpx.Response( + 200, json=graphql_response_ok, headers=graphql_headers_ok + ) + ) + + base_headers = { + 'Xpto': 'abc', + } + extra_headers = { + 'Extra': '123', + 'Accept': extra_accept_header, + } + + endpoint = HTTPXEndpoint(test_url, base_headers=base_headers) + data = endpoint(graphql_query, extra_headers=extra_headers) + assert data == graphql_response_ok + check_respx_route( + route, base_headers=base_headers, extra_headers=extra_headers + ) + + +def test_default_timeout(respx_mock): + 'Test if default timeout is respected' + + route = respx_mock.route(name='graphql', method='POST', url=test_url).mock( + return_value=httpx.Response( + 200, json=graphql_response_ok, headers=graphql_headers_ok + ) + ) + + timeout = 123 + + endpoint = HTTPXEndpoint(test_url, timeout=timeout) + data = endpoint(graphql_query) + assert data == graphql_response_ok + check_respx_route(route, timeout=timeout) + + +def test_call_timeout(respx_mock): + 'Test if call timeout takes precedence over default' + + route = respx_mock.route(name='graphql', method='POST', url=test_url).mock( + return_value=httpx.Response( + 200, json=graphql_response_ok, headers=graphql_headers_ok + ) + ) + + timeout = 123 + + endpoint = HTTPXEndpoint(test_url, timeout=1) + data = endpoint(graphql_query, timeout=timeout) + assert data == graphql_response_ok + check_respx_route(route, timeout=timeout) + + +def test_variables(respx_mock): + 'Test if variables are passed to server' + + route = respx_mock.route(name='graphql', method='POST', url=test_url).mock( + return_value=httpx.Response( + 200, json=graphql_response_ok, headers=graphql_headers_ok + ) + ) + + variables = {'repoOwner': 'owner', 'repoName': 'name'} + + endpoint = HTTPXEndpoint(test_url) + data = endpoint(graphql_query, variables) + assert data == graphql_response_ok + check_respx_route(route, variables=variables) + + +def test_operation_name(respx_mock): + 'Test if operation name is passed to server' + + route = respx_mock.route(name='graphql', method='POST', url=test_url).mock( + return_value=httpx.Response( + 200, json=graphql_response_ok, headers=graphql_headers_ok + ) + ) + + operation_name = 'xpto' + + endpoint = HTTPXEndpoint(test_url) + data = endpoint(graphql_query, operation_name=operation_name) + assert data == graphql_response_ok + check_respx_route(route, operation_name=operation_name) + + +def test_json_error(respx_mock): + 'Test if broken server responses (invalid JSON) is handled' + + route = respx_mock.route(name='graphql', method='POST', url=test_url).mock( + return_value=httpx.Response( + 200, + content=graphql_response_json_error, + headers=graphql_headers_ok, + ) + ) + + endpoint = HTTPXEndpoint(test_url) + data = endpoint(graphql_query) + + exc = get_json_exception(graphql_response_json_error) + got_exc = data['errors'][0].pop('exception') + assert isinstance( + got_exc, json.JSONDecodeError + ), '{} is not json.JSONDecodeError'.format(type(got_exc)) + + assert data == { + 'errors': [ + { + 'message': str(exc), + 'body': graphql_response_json_error.decode('utf-8'), + } + ], + 'data': None, + } + check_respx_route(route) + + +def test_get(respx_mock): + 'Test if HTTP method GET request works' + + route = respx_mock.route(name='graphql', method='GET', url=test_url).mock( + return_value=httpx.Response( + 200, json=graphql_response_ok, headers=graphql_headers_ok + ) + ) + + base_headers = { + 'Xpto': 'abc', + } + extra_headers = { + 'Extra': '123', + 'Accept': extra_accept_header, + } + variables = {'repoOwner': 'owner', 'repoName': 'name'} + operation_name = 'xpto' + + endpoint = HTTPXEndpoint(test_url, base_headers=base_headers, method='GET') + data = endpoint( + graphql_query, + extra_headers=extra_headers, + variables=variables, + operation_name=operation_name, + ) + assert data == graphql_response_ok + check_respx_route( + route, + base_headers=base_headers, + extra_headers=extra_headers, + variables=variables, + operation_name=operation_name, + ) + assert str(endpoint) == ( + 'HTTPXEndpoint(url={}, '.format(test_url) + + 'base_headers={}, '.format(base_headers) + + 'timeout=None, method=GET)' + ) + + +def test_server_reported_error(respx_mock): + 'Test if GraphQL errors reported with HTTP 200 is handled properly' + + route = respx_mock.route(name='graphql', method='POST', url=test_url).mock( + return_value=httpx.Response( + 200, json=graphql_response_error, headers=graphql_headers_ok + ) + ) + + endpoint = HTTPXEndpoint(test_url) + data = endpoint(graphql_query) + assert data == graphql_response_error + check_respx_route(route) + + +def test_server_http_error(respx_mock): + 'Test if HTTP error without JSON payload is handled' + + route = respx_mock.route(name='graphql', method='POST', url=test_url).mock( + return_value=httpx.Response( + 500, content=b'xpto', headers={'Xpto': 'abc'} + ) + ) + + endpoint = HTTPXEndpoint(test_url) + data = endpoint(graphql_query) + + got_exc = data['errors'][0].pop('exception') + assert isinstance( + got_exc, httpx.HTTPStatusError + ), '{} is not httpx.HTTPStatusError'.format(type(got_exc)) + + assert data == { + 'errors': [ + { + 'message': 'Server error \'500 Internal Server Error\' for url' + ' \'http://some-server.com/graphql\'\nFor more information che' + 'ck: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/' + '500', + 'status': 500, + 'headers': {'xpto': 'abc', 'content-length': '4'}, + 'body': 'xpto', + } + ], + 'data': None, + } + check_respx_route(route) + + +async def test_server_http_error_async(respx_mock): + 'Test if HTTP error without JSON payload is handled' + + route = respx_mock.route(name='graphql', method='POST', url=test_url).mock( + return_value=httpx.Response( + 500, content=b'xpto', headers={'Xpto': 'abc'} + ) + ) + + endpoint = HTTPXEndpoint(test_url, client=httpx.AsyncClient()) + data = await endpoint(graphql_query) + + got_exc = data['errors'][0].pop('exception') + assert isinstance( + got_exc, httpx.HTTPStatusError + ), '{} is not httpx.HTTPStatusError'.format(type(got_exc)) + + assert data == { + 'errors': [ + { + 'message': 'Server error \'500 Internal Server Error\' for url' + ' \'http://some-server.com/graphql\'\nFor more information che' + 'ck: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/' + '500', + 'status': 500, + 'headers': {'xpto': 'abc', 'content-length': '4'}, + 'body': 'xpto', + } + ], + 'data': None, + } + check_respx_route(route) + + +def test_server_http_non_conforming_json(respx_mock): + 'Test if HTTP error that is NOT conforming to GraphQL payload is handled' + + route = respx_mock.route(name='graphql', method='POST', url=test_url).mock( + return_value=httpx.Response(500, json={'message': 'xpto'}) + ) + + endpoint = HTTPXEndpoint(test_url) + data = endpoint(graphql_query) + + got_exc = data['errors'][0].pop('exception') + assert isinstance( + got_exc, httpx.HTTPStatusError + ), '{} is not httpx.HTTPStatusError'.format(type(got_exc)) + + assert data, { + 'errors': [ + { + 'message': 'hi', + 'status': 500, + 'headers': {'Content-Type': 'application/json'}, + 'body': '{"message": "xpto"}', + } + ], + 'data': None, + } + check_respx_route(route) + + +def test_server_error_broken_json(respx_mock): + 'Test if HTTP error with broken JSON payload is handled' + + route = respx_mock.route(name='graphql', method='POST', url=test_url).mock( + return_value=httpx.Response( + 500, content=b'xpto', headers={'Content-Type': 'application/json'} + ) + ) + + endpoint = HTTPXEndpoint(test_url) + data = endpoint(graphql_query) + got_exc = data['errors'][0].pop('exception') + assert isinstance( + got_exc, json.JSONDecodeError + ), '{} is not json.JSONDecodeError'.format(type(got_exc)) + + assert data, { + 'errors': [ + { + 'message': str(got_exc), + 'body': 'xpto', + } + ], + 'data': None, + } + check_respx_route(route) + + +def test_server_http_graphql_error(respx_mock): + 'Test if HTTP error that IS conforming to GraphQL payload is handled' + + route = respx_mock.route(name='graphql', method='POST', url=test_url).mock( + return_value=httpx.Response(500, json=graphql_response_error) + ) + + endpoint = HTTPXEndpoint(test_url) + data = endpoint(graphql_query) + + expected_data = graphql_response_error + expected_data.update( + { + 'status': 500, + 'headers': { + 'content-type': 'application/json', + 'content-length': '230', + }, + } + ) + + got_exc = data.pop('exception') + assert isinstance( + got_exc, httpx.HTTPStatusError + ), '{} is not httpx.HTTPStatusError'.format(type(got_exc)) + + assert data == expected_data + check_respx_route(route) + + +def test_server_http_single_error(respx_mock): + 'Test if HTTP error that a single JSON error string is handled' + + route = respx_mock.route(name='graphql', method='POST', url=test_url).mock( + return_value=httpx.Response(500, json={'errors': 'a string'}) + ) + + endpoint = HTTPXEndpoint(test_url) + data = endpoint(graphql_query) + + expected_data = {'errors': [{'message': 'a string'}]} + expected_data.update( + { + 'status': 500, + 'headers': { + 'content-type': 'application/json', + 'content-length': '21', + }, + } + ) + + got_exc = data.pop('exception') + assert isinstance( + got_exc, httpx.HTTPStatusError + ), '{} is not httpx.HTTPStatusError'.format(type(got_exc)) + + assert data == expected_data + check_respx_route(route) + + +def test_server_http_error_string_list(respx_mock): + 'Test if HTTP error that a JSON error string list is handled' + + route = respx_mock.route(name='graphql', method='POST', url=test_url).mock( + return_value=httpx.Response(500, json={'errors': ['a', 'b']}) + ) + + endpoint = HTTPXEndpoint(test_url) + data = endpoint(graphql_query) + + expected_data = {'errors': [{'message': 'a'}, {'message': 'b'}]} + expected_data.update( + { + 'status': 500, + 'headers': { + 'content-type': 'application/json', + 'content-length': '20', + }, + } + ) + + got_exc = data.pop('exception') + assert isinstance( + got_exc, httpx.HTTPStatusError + ), '{} is not httpx.HTTPStatusError'.format(type(got_exc)) + + assert data == expected_data + check_respx_route(route) + + +def test_server_http_error_list_message(respx_mock): + 'Test if HTTP error that a JSON error with messages being a list' + + route = respx_mock.route(name='graphql', method='POST', url=test_url).mock( + return_value=httpx.Response( + 500, json={'errors': [{'message': [1, 2]}]} + ) + ) + + endpoint = HTTPXEndpoint(test_url) + data = endpoint(graphql_query) + + expected_data = {'errors': [{'message': '[1, 2]'}]} + expected_data.update( + { + 'status': 500, + 'headers': { + 'content-type': 'application/json', + 'content-length': '30', + }, + } + ) + + got_exc = data.pop('exception') + assert isinstance( + got_exc, httpx.HTTPStatusError + ), '{} is not httpx.HTTPStatusError'.format(type(got_exc)) + + assert data == expected_data + check_respx_route(route)