Skip to content

Allow to configure the introspection query sent to recover the schema #402

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/gql-cli/intro.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion docs/usage/validation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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:

Expand Down
63 changes: 61 additions & 2 deletions gql/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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://"
Expand Down Expand Up @@ -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)",
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:

Expand Down
16 changes: 11 additions & 5 deletions gql/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -86,7 +87,9 @@ def __init__(
See :ref:`schema_validation`
:param transport: The provided :ref:`transport <Transports>`.
: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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down
42 changes: 42 additions & 0 deletions tests/starwars/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions tests/starwars/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)
Expand Down
62 changes: 62 additions & 0 deletions tests/starwars/test_introspection.py
Original file line number Diff line number Diff line change
@@ -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
44 changes: 44 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)