From 54b2db3265e5b9d4add96c267ecfa69585a0ecd7 Mon Sep 17 00:00:00 2001 From: Leszek Hanusz Date: Tue, 28 Mar 2023 17:31:27 +0200 Subject: [PATCH 1/2] Implement possibility to change introspection query parameters --- gql/client.py | 16 ++++--- tests/starwars/fixtures.py | 42 +++++++++++++++++++ tests/starwars/schema.py | 5 +++ tests/starwars/test_introspection.py | 62 ++++++++++++++++++++++++++++ 4 files changed, 120 insertions(+), 5 deletions(-) create mode 100644 tests/starwars/test_introspection.py diff --git a/gql/client.py b/gql/client.py index 690c2fce..f6302987 100644 --- a/gql/client.py +++ b/gql/client.py @@ -76,6 +76,7 @@ def __init__( introspection: Optional[IntrospectionQuery] = None, transport: Optional[Union[Transport, AsyncTransport]] = None, fetch_schema_from_transport: bool = False, + introspection_args: Optional[Dict] = None, execute_timeout: Optional[Union[int, float]] = 10, serialize_variables: bool = False, parse_results: bool = False, @@ -86,7 +87,9 @@ def __init__( See :ref:`schema_validation` :param transport: The provided :ref:`transport `. :param fetch_schema_from_transport: Boolean to indicate that if we want to fetch - the schema from the transport using an introspection query + the schema from the transport using an introspection query. + :param introspection_args: arguments passed to the get_introspection_query + method of graphql-core. :param execute_timeout: The maximum time in seconds for the execution of a request before a TimeoutError is raised. Only used for async transports. Passing None results in waiting forever for a response. @@ -132,6 +135,9 @@ def __init__( # Flag to indicate that we need to fetch the schema from the transport # On async transports, we fetch the schema before executing the first query self.fetch_schema_from_transport: bool = fetch_schema_from_transport + self.introspection_args = ( + {} if introspection_args is None else introspection_args + ) # Enforced timeout of the execute function (only for async transports) self.execute_timeout = execute_timeout @@ -879,7 +885,8 @@ def fetch_schema(self) -> None: Don't use this function and instead set the fetch_schema_from_transport attribute to True""" - execution_result = self.transport.execute(parse(get_introspection_query())) + introspection_query = get_introspection_query(**self.client.introspection_args) + execution_result = self.transport.execute(parse(introspection_query)) self.client._build_schema_from_introspection(execution_result) @@ -1250,9 +1257,8 @@ async def fetch_schema(self) -> None: Don't use this function and instead set the fetch_schema_from_transport attribute to True""" - execution_result = await self.transport.execute( - parse(get_introspection_query()) - ) + introspection_query = get_introspection_query(**self.client.introspection_args) + execution_result = await self.transport.execute(parse(introspection_query)) self.client._build_schema_from_introspection(execution_result) diff --git a/tests/starwars/fixtures.py b/tests/starwars/fixtures.py index efbb1b0e..59d7ddfa 100644 --- a/tests/starwars/fixtures.py +++ b/tests/starwars/fixtures.py @@ -144,3 +144,45 @@ def create_review(episode, review): reviews[episode].append(review) review["episode"] = episode return review + + +async def make_starwars_backend(aiohttp_server): + from aiohttp import web + from .schema import StarWarsSchema + from graphql import graphql_sync + + async def handler(request): + data = await request.json() + source = data["query"] + + try: + variables = data["variables"] + except KeyError: + variables = None + + result = graphql_sync(StarWarsSchema, source, variable_values=variables) + + return web.json_response( + { + "data": result.data, + "errors": [str(e) for e in result.errors] if result.errors else None, + } + ) + + app = web.Application() + app.router.add_route("POST", "/", handler) + server = await aiohttp_server(app) + + return server + + +async def make_starwars_transport(aiohttp_server): + from gql.transport.aiohttp import AIOHTTPTransport + + server = await make_starwars_backend(aiohttp_server) + + url = server.make_url("/") + + transport = AIOHTTPTransport(url=url, timeout=10) + + return transport diff --git a/tests/starwars/schema.py b/tests/starwars/schema.py index c3db0a3d..5f9a04b4 100644 --- a/tests/starwars/schema.py +++ b/tests/starwars/schema.py @@ -155,6 +155,11 @@ "commentary": GraphQLInputField( GraphQLString, description="Comment about the movie, optional" ), + "deprecated_input_field": GraphQLInputField( + GraphQLString, + description="deprecated field example", + deprecation_reason="deprecated for testing", + ), }, description="The input object sent when someone is creating a new review", ) diff --git a/tests/starwars/test_introspection.py b/tests/starwars/test_introspection.py new file mode 100644 index 00000000..c3063808 --- /dev/null +++ b/tests/starwars/test_introspection.py @@ -0,0 +1,62 @@ +import pytest +from graphql import print_schema + +from gql import Client + +from .fixtures import make_starwars_transport + +# Marking all tests in this file with the aiohttp marker +pytestmark = pytest.mark.aiohttp + + +@pytest.mark.asyncio +async def test_starwars_introspection_args(event_loop, aiohttp_server): + + transport = await make_starwars_transport(aiohttp_server) + + # First fetch the schema from transport using default introspection query + # We should receive descriptions in the schema but not deprecated input fields + async with Client( + transport=transport, + fetch_schema_from_transport=True, + ) as session: + + schema_str = print_schema(session.client.schema) + print(schema_str) + + assert '"""The number of stars this review gave, 1-5"""' in schema_str + assert "deprecated_input_field" not in schema_str + + # Then fetch the schema from transport using an introspection query + # without requesting descriptions + # We should NOT receive descriptions in the schema + async with Client( + transport=transport, + fetch_schema_from_transport=True, + introspection_args={ + "descriptions": False, + }, + ) as session: + + schema_str = print_schema(session.client.schema) + print(schema_str) + + assert '"""The number of stars this review gave, 1-5"""' not in schema_str + assert "deprecated_input_field" not in schema_str + + # Then fetch the schema from transport using and introspection query + # requiring deprecated input fields + # We should receive descriptions in the schema and deprecated input fields + async with Client( + transport=transport, + fetch_schema_from_transport=True, + introspection_args={ + "input_value_deprecation": True, + }, + ) as session: + + schema_str = print_schema(session.client.schema) + print(schema_str) + + assert '"""The number of stars this review gave, 1-5"""' in schema_str + assert "deprecated_input_field" in schema_str From 6a66a7fbaace6194ced98fe55422963570594600 Mon Sep 17 00:00:00 2001 From: Leszek Hanusz Date: Wed, 29 Mar 2023 14:45:44 +0200 Subject: [PATCH 2/2] Add --schema-download argument to gql-cli --- docs/gql-cli/intro.rst | 10 +++++++ docs/usage/validation.rst | 2 +- gql/cli.py | 63 +++++++++++++++++++++++++++++++++++++-- tests/test_cli.py | 44 +++++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 3 deletions(-) diff --git a/docs/gql-cli/intro.rst b/docs/gql-cli/intro.rst index 93f16d32..925958ee 100644 --- a/docs/gql-cli/intro.rst +++ b/docs/gql-cli/intro.rst @@ -78,3 +78,13 @@ Print the GraphQL schema in a file .. code-block:: shell $ gql-cli https://countries.trevorblades.com/graphql --print-schema > schema.graphql + +.. note:: + + By default, deprecated input fields are not requested from the backend. + You can add :code:`--schema-download input_value_deprecation:true` to request them. + +.. note:: + + You can add :code:`--schema-download descriptions:false` to request a compact schema + without comments. diff --git a/docs/usage/validation.rst b/docs/usage/validation.rst index 18b1cda1..f9711f31 100644 --- a/docs/usage/validation.rst +++ b/docs/usage/validation.rst @@ -24,7 +24,7 @@ The schema can be provided as a String (which is usually stored in a .graphql fi .. note:: You can download a schema from a server by using :ref:`gql-cli ` - :code:`$ gql-cli https://SERVER_URL/graphql --print-schema > schema.graphql` + :code:`$ gql-cli https://SERVER_URL/graphql --print-schema --schema-download input_value_deprecation:true > schema.graphql` OR can be created using python classes: diff --git a/gql/cli.py b/gql/cli.py index 2a6ff3f5..dd991546 100644 --- a/gql/cli.py +++ b/gql/cli.py @@ -3,7 +3,8 @@ import logging import signal as signal_module import sys -from argparse import ArgumentParser, Namespace, RawDescriptionHelpFormatter +import textwrap +from argparse import ArgumentParser, Namespace, RawTextHelpFormatter from typing import Any, Dict, Optional from graphql import GraphQLError, print_schema @@ -78,7 +79,7 @@ def get_parser(with_examples: bool = False) -> ArgumentParser: parser = ArgumentParser( description=description, epilog=examples if with_examples else None, - formatter_class=RawDescriptionHelpFormatter, + formatter_class=RawTextHelpFormatter, ) parser.add_argument( "server", help="the server url starting with http://, https://, ws:// or wss://" @@ -122,6 +123,27 @@ def get_parser(with_examples: bool = False) -> ArgumentParser: action="store_true", dest="print_schema", ) + parser.add_argument( + "--schema-download", + nargs="*", + help=textwrap.dedent( + """select the introspection query arguments to download the schema. + Only useful if --print-schema is used. + By default, it will: + + - request field descriptions + - not request deprecated input fields + + Possible options: + + - descriptions:false for a compact schema without comments + - input_value_deprecation:true to download deprecated input fields + - specified_by_url:true + - schema_description:true + - directive_is_repeatable:true""" + ), + dest="schema_download", + ) parser.add_argument( "--execute-timeout", help="set the execute_timeout argument of the Client (default: 10)", @@ -362,6 +384,42 @@ def get_transport(args: Namespace) -> Optional[AsyncTransport]: return None +def get_introspection_args(args: Namespace) -> Dict: + """Get the introspection args depending on the schema_download argument""" + + # Parse the headers argument + introspection_args = {} + + possible_args = [ + "descriptions", + "specified_by_url", + "directive_is_repeatable", + "schema_description", + "input_value_deprecation", + ] + + if args.schema_download is not None: + for arg in args.schema_download: + + try: + # Split only the first colon (throw a ValueError if no colon is present) + arg_key, arg_value = arg.split(":", 1) + + if arg_key not in possible_args: + raise ValueError(f"Invalid schema_download: {args.schema_download}") + + arg_value = arg_value.lower() + if arg_value not in ["true", "false"]: + raise ValueError(f"Invalid schema_download: {args.schema_download}") + + introspection_args[arg_key] = arg_value == "true" + + except ValueError: + raise ValueError(f"Invalid schema_download: {args.schema_download}") + + return introspection_args + + async def main(args: Namespace) -> int: """Main entrypoint of the gql-cli script @@ -395,6 +453,7 @@ async def main(args: Namespace) -> int: async with Client( transport=transport, fetch_schema_from_transport=args.print_schema, + introspection_args=get_introspection_args(args), execute_timeout=args.execute_timeout, ) as session: diff --git a/tests/test_cli.py b/tests/test_cli.py index 359e94fb..f0534957 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5,6 +5,7 @@ from gql import __version__ from gql.cli import ( get_execute_args, + get_introspection_args, get_parser, get_transport, get_transport_args, @@ -376,3 +377,46 @@ def test_cli_ep_version(script_runner): assert ret.stdout == f"v{__version__}\n" assert ret.stderr == "" + + +def test_cli_parse_schema_download(parser): + + args = parser.parse_args( + [ + "https://your_server.com", + "--schema-download", + "descriptions:false", + "input_value_deprecation:true", + "specified_by_url:True", + "schema_description:true", + "directive_is_repeatable:true", + "--print-schema", + ] + ) + + introspection_args = get_introspection_args(args) + + expected_args = { + "descriptions": False, + "input_value_deprecation": True, + "specified_by_url": True, + "schema_description": True, + "directive_is_repeatable": True, + } + + assert introspection_args == expected_args + + +@pytest.mark.parametrize( + "invalid_args", + [ + ["https://your_server.com", "--schema-download", "ArgWithoutColon"], + ["https://your_server.com", "--schema-download", "blahblah:true"], + ["https://your_server.com", "--schema-download", "descriptions:invalid_bool"], + ], +) +def test_cli_parse_schema_download_invalid_arg(parser, invalid_args): + args = parser.parse_args(invalid_args) + + with pytest.raises(ValueError): + get_introspection_args(args)