Skip to content

Commit baa323c

Browse files
authored
Allow to configure the introspection query sent to recover the schema (#402)
* Implement possibility to change introspection query parameters * Add --schema-download argument to gql-cli
1 parent 6df7cf9 commit baa323c

File tree

8 files changed

+236
-8
lines changed

8 files changed

+236
-8
lines changed

docs/gql-cli/intro.rst

+10
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,13 @@ Print the GraphQL schema in a file
7878
.. code-block:: shell
7979
8080
$ gql-cli https://countries.trevorblades.com/graphql --print-schema > schema.graphql
81+
82+
.. note::
83+
84+
By default, deprecated input fields are not requested from the backend.
85+
You can add :code:`--schema-download input_value_deprecation:true` to request them.
86+
87+
.. note::
88+
89+
You can add :code:`--schema-download descriptions:false` to request a compact schema
90+
without comments.

docs/usage/validation.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ The schema can be provided as a String (which is usually stored in a .graphql fi
2424
.. note::
2525
You can download a schema from a server by using :ref:`gql-cli <gql_cli>`
2626

27-
:code:`$ gql-cli https://SERVER_URL/graphql --print-schema > schema.graphql`
27+
:code:`$ gql-cli https://SERVER_URL/graphql --print-schema --schema-download input_value_deprecation:true > schema.graphql`
2828

2929
OR can be created using python classes:
3030

gql/cli.py

+61-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
import logging
44
import signal as signal_module
55
import sys
6-
from argparse import ArgumentParser, Namespace, RawDescriptionHelpFormatter
6+
import textwrap
7+
from argparse import ArgumentParser, Namespace, RawTextHelpFormatter
78
from typing import Any, Dict, Optional
89

910
from graphql import GraphQLError, print_schema
@@ -78,7 +79,7 @@ def get_parser(with_examples: bool = False) -> ArgumentParser:
7879
parser = ArgumentParser(
7980
description=description,
8081
epilog=examples if with_examples else None,
81-
formatter_class=RawDescriptionHelpFormatter,
82+
formatter_class=RawTextHelpFormatter,
8283
)
8384
parser.add_argument(
8485
"server", help="the server url starting with http://, https://, ws:// or wss://"
@@ -122,6 +123,27 @@ def get_parser(with_examples: bool = False) -> ArgumentParser:
122123
action="store_true",
123124
dest="print_schema",
124125
)
126+
parser.add_argument(
127+
"--schema-download",
128+
nargs="*",
129+
help=textwrap.dedent(
130+
"""select the introspection query arguments to download the schema.
131+
Only useful if --print-schema is used.
132+
By default, it will:
133+
134+
- request field descriptions
135+
- not request deprecated input fields
136+
137+
Possible options:
138+
139+
- descriptions:false for a compact schema without comments
140+
- input_value_deprecation:true to download deprecated input fields
141+
- specified_by_url:true
142+
- schema_description:true
143+
- directive_is_repeatable:true"""
144+
),
145+
dest="schema_download",
146+
)
125147
parser.add_argument(
126148
"--execute-timeout",
127149
help="set the execute_timeout argument of the Client (default: 10)",
@@ -362,6 +384,42 @@ def get_transport(args: Namespace) -> Optional[AsyncTransport]:
362384
return None
363385

364386

387+
def get_introspection_args(args: Namespace) -> Dict:
388+
"""Get the introspection args depending on the schema_download argument"""
389+
390+
# Parse the headers argument
391+
introspection_args = {}
392+
393+
possible_args = [
394+
"descriptions",
395+
"specified_by_url",
396+
"directive_is_repeatable",
397+
"schema_description",
398+
"input_value_deprecation",
399+
]
400+
401+
if args.schema_download is not None:
402+
for arg in args.schema_download:
403+
404+
try:
405+
# Split only the first colon (throw a ValueError if no colon is present)
406+
arg_key, arg_value = arg.split(":", 1)
407+
408+
if arg_key not in possible_args:
409+
raise ValueError(f"Invalid schema_download: {args.schema_download}")
410+
411+
arg_value = arg_value.lower()
412+
if arg_value not in ["true", "false"]:
413+
raise ValueError(f"Invalid schema_download: {args.schema_download}")
414+
415+
introspection_args[arg_key] = arg_value == "true"
416+
417+
except ValueError:
418+
raise ValueError(f"Invalid schema_download: {args.schema_download}")
419+
420+
return introspection_args
421+
422+
365423
async def main(args: Namespace) -> int:
366424
"""Main entrypoint of the gql-cli script
367425
@@ -395,6 +453,7 @@ async def main(args: Namespace) -> int:
395453
async with Client(
396454
transport=transport,
397455
fetch_schema_from_transport=args.print_schema,
456+
introspection_args=get_introspection_args(args),
398457
execute_timeout=args.execute_timeout,
399458
) as session:
400459

gql/client.py

+11-5
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ def __init__(
7676
introspection: Optional[IntrospectionQuery] = None,
7777
transport: Optional[Union[Transport, AsyncTransport]] = None,
7878
fetch_schema_from_transport: bool = False,
79+
introspection_args: Optional[Dict] = None,
7980
execute_timeout: Optional[Union[int, float]] = 10,
8081
serialize_variables: bool = False,
8182
parse_results: bool = False,
@@ -86,7 +87,9 @@ def __init__(
8687
See :ref:`schema_validation`
8788
:param transport: The provided :ref:`transport <Transports>`.
8889
:param fetch_schema_from_transport: Boolean to indicate that if we want to fetch
89-
the schema from the transport using an introspection query
90+
the schema from the transport using an introspection query.
91+
:param introspection_args: arguments passed to the get_introspection_query
92+
method of graphql-core.
9093
:param execute_timeout: The maximum time in seconds for the execution of a
9194
request before a TimeoutError is raised. Only used for async transports.
9295
Passing None results in waiting forever for a response.
@@ -132,6 +135,9 @@ def __init__(
132135
# Flag to indicate that we need to fetch the schema from the transport
133136
# On async transports, we fetch the schema before executing the first query
134137
self.fetch_schema_from_transport: bool = fetch_schema_from_transport
138+
self.introspection_args = (
139+
{} if introspection_args is None else introspection_args
140+
)
135141

136142
# Enforced timeout of the execute function (only for async transports)
137143
self.execute_timeout = execute_timeout
@@ -879,7 +885,8 @@ def fetch_schema(self) -> None:
879885
880886
Don't use this function and instead set the fetch_schema_from_transport
881887
attribute to True"""
882-
execution_result = self.transport.execute(parse(get_introspection_query()))
888+
introspection_query = get_introspection_query(**self.client.introspection_args)
889+
execution_result = self.transport.execute(parse(introspection_query))
883890

884891
self.client._build_schema_from_introspection(execution_result)
885892

@@ -1250,9 +1257,8 @@ async def fetch_schema(self) -> None:
12501257
12511258
Don't use this function and instead set the fetch_schema_from_transport
12521259
attribute to True"""
1253-
execution_result = await self.transport.execute(
1254-
parse(get_introspection_query())
1255-
)
1260+
introspection_query = get_introspection_query(**self.client.introspection_args)
1261+
execution_result = await self.transport.execute(parse(introspection_query))
12561262

12571263
self.client._build_schema_from_introspection(execution_result)
12581264

tests/starwars/fixtures.py

+42
Original file line numberDiff line numberDiff line change
@@ -144,3 +144,45 @@ def create_review(episode, review):
144144
reviews[episode].append(review)
145145
review["episode"] = episode
146146
return review
147+
148+
149+
async def make_starwars_backend(aiohttp_server):
150+
from aiohttp import web
151+
from .schema import StarWarsSchema
152+
from graphql import graphql_sync
153+
154+
async def handler(request):
155+
data = await request.json()
156+
source = data["query"]
157+
158+
try:
159+
variables = data["variables"]
160+
except KeyError:
161+
variables = None
162+
163+
result = graphql_sync(StarWarsSchema, source, variable_values=variables)
164+
165+
return web.json_response(
166+
{
167+
"data": result.data,
168+
"errors": [str(e) for e in result.errors] if result.errors else None,
169+
}
170+
)
171+
172+
app = web.Application()
173+
app.router.add_route("POST", "/", handler)
174+
server = await aiohttp_server(app)
175+
176+
return server
177+
178+
179+
async def make_starwars_transport(aiohttp_server):
180+
from gql.transport.aiohttp import AIOHTTPTransport
181+
182+
server = await make_starwars_backend(aiohttp_server)
183+
184+
url = server.make_url("/")
185+
186+
transport = AIOHTTPTransport(url=url, timeout=10)
187+
188+
return transport

tests/starwars/schema.py

+5
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,11 @@
155155
"commentary": GraphQLInputField(
156156
GraphQLString, description="Comment about the movie, optional"
157157
),
158+
"deprecated_input_field": GraphQLInputField(
159+
GraphQLString,
160+
description="deprecated field example",
161+
deprecation_reason="deprecated for testing",
162+
),
158163
},
159164
description="The input object sent when someone is creating a new review",
160165
)

tests/starwars/test_introspection.py

+62
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import pytest
2+
from graphql import print_schema
3+
4+
from gql import Client
5+
6+
from .fixtures import make_starwars_transport
7+
8+
# Marking all tests in this file with the aiohttp marker
9+
pytestmark = pytest.mark.aiohttp
10+
11+
12+
@pytest.mark.asyncio
13+
async def test_starwars_introspection_args(event_loop, aiohttp_server):
14+
15+
transport = await make_starwars_transport(aiohttp_server)
16+
17+
# First fetch the schema from transport using default introspection query
18+
# We should receive descriptions in the schema but not deprecated input fields
19+
async with Client(
20+
transport=transport,
21+
fetch_schema_from_transport=True,
22+
) as session:
23+
24+
schema_str = print_schema(session.client.schema)
25+
print(schema_str)
26+
27+
assert '"""The number of stars this review gave, 1-5"""' in schema_str
28+
assert "deprecated_input_field" not in schema_str
29+
30+
# Then fetch the schema from transport using an introspection query
31+
# without requesting descriptions
32+
# We should NOT receive descriptions in the schema
33+
async with Client(
34+
transport=transport,
35+
fetch_schema_from_transport=True,
36+
introspection_args={
37+
"descriptions": False,
38+
},
39+
) as session:
40+
41+
schema_str = print_schema(session.client.schema)
42+
print(schema_str)
43+
44+
assert '"""The number of stars this review gave, 1-5"""' not in schema_str
45+
assert "deprecated_input_field" not in schema_str
46+
47+
# Then fetch the schema from transport using and introspection query
48+
# requiring deprecated input fields
49+
# We should receive descriptions in the schema and deprecated input fields
50+
async with Client(
51+
transport=transport,
52+
fetch_schema_from_transport=True,
53+
introspection_args={
54+
"input_value_deprecation": True,
55+
},
56+
) as session:
57+
58+
schema_str = print_schema(session.client.schema)
59+
print(schema_str)
60+
61+
assert '"""The number of stars this review gave, 1-5"""' in schema_str
62+
assert "deprecated_input_field" in schema_str

tests/test_cli.py

+44
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from gql import __version__
66
from gql.cli import (
77
get_execute_args,
8+
get_introspection_args,
89
get_parser,
910
get_transport,
1011
get_transport_args,
@@ -376,3 +377,46 @@ def test_cli_ep_version(script_runner):
376377

377378
assert ret.stdout == f"v{__version__}\n"
378379
assert ret.stderr == ""
380+
381+
382+
def test_cli_parse_schema_download(parser):
383+
384+
args = parser.parse_args(
385+
[
386+
"https://your_server.com",
387+
"--schema-download",
388+
"descriptions:false",
389+
"input_value_deprecation:true",
390+
"specified_by_url:True",
391+
"schema_description:true",
392+
"directive_is_repeatable:true",
393+
"--print-schema",
394+
]
395+
)
396+
397+
introspection_args = get_introspection_args(args)
398+
399+
expected_args = {
400+
"descriptions": False,
401+
"input_value_deprecation": True,
402+
"specified_by_url": True,
403+
"schema_description": True,
404+
"directive_is_repeatable": True,
405+
}
406+
407+
assert introspection_args == expected_args
408+
409+
410+
@pytest.mark.parametrize(
411+
"invalid_args",
412+
[
413+
["https://your_server.com", "--schema-download", "ArgWithoutColon"],
414+
["https://your_server.com", "--schema-download", "blahblah:true"],
415+
["https://your_server.com", "--schema-download", "descriptions:invalid_bool"],
416+
],
417+
)
418+
def test_cli_parse_schema_download_invalid_arg(parser, invalid_args):
419+
args = parser.parse_args(invalid_args)
420+
421+
with pytest.raises(ValueError):
422+
get_introspection_args(args)

0 commit comments

Comments
 (0)