Skip to content

Commit 3e6849f

Browse files
committed
RFC: Assert subscription field is not introspection.
Replicates graphql/graphql-js@d5eac89
1 parent 65d8d3b commit 3e6849f

File tree

3 files changed

+296
-18
lines changed

3 files changed

+296
-18
lines changed
Lines changed: 76 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,91 @@
1-
from typing import Any
1+
from typing import Any, Dict, cast
22

33
from ...error import GraphQLError
4-
from ...language import OperationDefinitionNode, OperationType
5-
from . import ASTValidationRule
4+
from ...execution import ExecutionContext, default_field_resolver, default_type_resolver
5+
from ...language import (
6+
FieldNode,
7+
FragmentDefinitionNode,
8+
OperationDefinitionNode,
9+
OperationType,
10+
)
11+
from . import ValidationRule
612

713
__all__ = ["SingleFieldSubscriptionsRule"]
814

915

10-
class SingleFieldSubscriptionsRule(ASTValidationRule):
11-
"""Subscriptions must only include one field.
16+
class SingleFieldSubscriptionsRule(ValidationRule):
17+
"""Subscriptions must only include a single non-introspection field.
1218
13-
A GraphQL subscription is valid only if it contains a single root.
19+
A GraphQL subscription is valid only if it contains a single root field and
20+
that root field is not an introspection field.
1421
"""
1522

1623
def enter_operation_definition(
1724
self, node: OperationDefinitionNode, *_args: Any
1825
) -> None:
1926
if node.operation == OperationType.SUBSCRIPTION:
20-
if len(node.selection_set.selections) != 1:
21-
self.report_error(
22-
GraphQLError(
23-
(
24-
f"Subscription '{node.name.value}'"
25-
if node.name
26-
else "Anonymous Subscription"
27+
schema = self.context.schema
28+
subscription_type = schema.subscription_type
29+
if subscription_type:
30+
operation_name = node.name.value if node.name else None
31+
variable_values: Dict[str, Any] = {}
32+
document = self.context.document
33+
fragments: Dict[str, FragmentDefinitionNode] = {
34+
definition.name.value: definition
35+
for definition in document.definitions
36+
if isinstance(definition, FragmentDefinitionNode)
37+
}
38+
fake_execution_context = ExecutionContext(
39+
schema,
40+
fragments,
41+
root_value=None,
42+
context_value=None,
43+
operation=node,
44+
variable_values=variable_values,
45+
field_resolver=default_field_resolver,
46+
type_resolver=default_type_resolver,
47+
errors=[],
48+
middleware_manager=None,
49+
is_awaitable=None,
50+
)
51+
fields = fake_execution_context.collect_fields(
52+
subscription_type, node.selection_set, {}, set()
53+
)
54+
if len(fields) > 1:
55+
field_selection_lists = list(fields.values())
56+
extra_field_selection_lists = field_selection_lists[1:]
57+
extra_field_selection = [
58+
field
59+
for fields in extra_field_selection_lists
60+
for field in (
61+
fields
62+
if isinstance(fields, list)
63+
else [cast(FieldNode, fields)]
64+
)
65+
]
66+
self.report_error(
67+
GraphQLError(
68+
(
69+
"Anonymous Subscription"
70+
if operation_name is None
71+
else f"Subscription '{operation_name}'"
72+
)
73+
+ " must select only one top level field.",
74+
extra_field_selection,
2775
)
28-
+ " must select only one top level field.",
29-
node.selection_set.selections[1:],
3076
)
31-
)
77+
for field_nodes in fields.values():
78+
field = field_nodes[0]
79+
field_name = field.name.value
80+
if field_name.startswith("__"):
81+
self.report_error(
82+
GraphQLError(
83+
(
84+
"Anonymous Subscription"
85+
if operation_name is None
86+
else f"Subscription '{operation_name}'"
87+
)
88+
+ " must not select an introspection top level field.",
89+
field_nodes,
90+
)
91+
)

tests/validation/harness.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@
77
from graphql.validation import ValidationRule, SDLValidationRule
88
from graphql.validation.validate import validate, validate_sdl
99

10+
__all__ = [
11+
"test_schema",
12+
"empty_schema",
13+
"assert_validation_errors",
14+
"assert_sdl_validation_errors",
15+
]
16+
1017
test_schema = build_schema(
1118
"""
1219
interface Being {
@@ -127,8 +134,23 @@
127134
complicatedArgs: ComplicatedArgs
128135
}
129136
137+
type Message {
138+
body: String
139+
sender: String
140+
}
141+
142+
type SubscriptionRoot {
143+
importantEmails: [String]
144+
notImportantEmails: [String]
145+
moreImportantEmails: [String]
146+
spamEmails: [String]
147+
deletedEmails: [String]
148+
newMessage: Message
149+
}
150+
130151
schema {
131152
query: QueryRoot
153+
subscription: SubscriptionRoot
132154
}
133155
134156
directive @onQuery on QUERY
@@ -142,6 +164,18 @@
142164
"""
143165
)
144166

167+
empty_schema = build_schema(
168+
"""
169+
type QueryRoot {
170+
empty: Boolean
171+
}
172+
173+
schema {
174+
query: QueryRoot
175+
}
176+
"""
177+
)
178+
145179

146180
def assert_validation_errors(
147181
rule: Type[ValidationRule],

tests/validation/test_single_field_subscriptions.py

Lines changed: 186 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from graphql.validation import SingleFieldSubscriptionsRule
44

5-
from .harness import assert_validation_errors
5+
from .harness import assert_validation_errors, empty_schema
66

77
assert_errors = partial(assert_validation_errors, SingleFieldSubscriptionsRule)
88

@@ -19,6 +19,41 @@ def valid_subscription():
1919
"""
2020
)
2121

22+
def valid_subscription_with_fragment():
23+
assert_valid(
24+
"""
25+
subscription sub {
26+
...newMessageFields
27+
}
28+
29+
fragment newMessageFields on SubscriptionRoot {
30+
newMessage {
31+
body
32+
sender
33+
}
34+
}
35+
"""
36+
)
37+
38+
def valid_subscription_with_fragment_and_field():
39+
assert_valid(
40+
"""
41+
subscription sub {
42+
newMessage {
43+
body
44+
}
45+
...newMessageFields
46+
}
47+
48+
fragment newMessageFields on SubscriptionRoot {
49+
newMessage {
50+
body
51+
sender
52+
}
53+
}
54+
"""
55+
)
56+
2257
def fails_with_more_than_one_root_field():
2358
assert_errors(
2459
"""
@@ -49,7 +84,37 @@ def fails_with_more_than_one_root_field_including_introspection():
4984
"message": "Subscription 'ImportantEmails'"
5085
" must select only one top level field.",
5186
"locations": [(4, 15)],
52-
}
87+
},
88+
{
89+
"message": "Subscription 'ImportantEmails'"
90+
" must not select an introspection top level field.",
91+
"locations": [(4, 15)],
92+
},
93+
],
94+
)
95+
96+
def fails_with_more_than_one_root_field_including_aliased_introspection():
97+
assert_errors(
98+
"""
99+
subscription ImportantEmails {
100+
importantEmails
101+
...Introspection
102+
}
103+
fragment Introspection on SubscriptionRoot {
104+
typename: __typename
105+
}
106+
""",
107+
[
108+
{
109+
"message": "Subscription 'ImportantEmails'"
110+
" must select only one top level field.",
111+
"locations": [(7, 15)],
112+
},
113+
{
114+
"message": "Subscription 'ImportantEmails'"
115+
" must not select an introspection top level field.",
116+
"locations": [(7, 15)],
117+
},
53118
],
54119
)
55120

@@ -71,6 +136,82 @@ def fails_with_many_more_than_one_root_field():
71136
],
72137
)
73138

139+
def fails_with_more_than_one_root_field_via_fragments():
140+
assert_errors(
141+
"""
142+
subscription ImportantEmails {
143+
importantEmails
144+
... {
145+
more: moreImportantEmails
146+
}
147+
...NotImportantEmails
148+
}
149+
fragment NotImportantEmails on SubscriptionRoot {
150+
notImportantEmails
151+
deleted: deletedEmails
152+
...SpamEmails
153+
}
154+
fragment SpamEmails on SubscriptionRoot {
155+
spamEmails
156+
}
157+
""",
158+
[
159+
{
160+
"message": "Subscription 'ImportantEmails'"
161+
" must select only one top level field.",
162+
"locations": [(5, 17), (10, 15), (11, 15), (15, 15)],
163+
},
164+
],
165+
)
166+
167+
def does_not_infinite_loop_on_recursive_fragments():
168+
assert_errors(
169+
"""
170+
subscription NoInfiniteLoop {
171+
...A
172+
}
173+
fragment A on SubscriptionRoot {
174+
...A
175+
}
176+
""",
177+
[],
178+
)
179+
180+
def fails_with_more_than_one_root_field_via_fragments_anonymous():
181+
assert_errors(
182+
"""
183+
subscription {
184+
importantEmails
185+
... {
186+
more: moreImportantEmails
187+
...NotImportantEmails
188+
}
189+
...NotImportantEmails
190+
}
191+
fragment NotImportantEmails on SubscriptionRoot {
192+
notImportantEmails
193+
deleted: deletedEmails
194+
... {
195+
... {
196+
archivedEmails
197+
}
198+
}
199+
...SpamEmails
200+
}
201+
fragment SpamEmails on SubscriptionRoot {
202+
spamEmails
203+
...NonExistentFragment
204+
}
205+
""",
206+
[
207+
{
208+
"message": "Anonymous Subscription"
209+
" must select only one top level field.",
210+
"locations": [(5, 17), (11, 15), (12, 15), (15, 19), (21, 15)],
211+
},
212+
],
213+
)
214+
74215
def fails_with_more_than_one_root_field_in_anonymous_subscriptions():
75216
assert_errors(
76217
"""
@@ -87,3 +228,46 @@ def fails_with_more_than_one_root_field_in_anonymous_subscriptions():
87228
}
88229
],
89230
)
231+
232+
def fails_with_introspection_field():
233+
assert_errors(
234+
"""
235+
subscription ImportantEmails {
236+
__typename
237+
}
238+
""",
239+
[
240+
{
241+
"message": "Subscription 'ImportantEmails' must not"
242+
" select an introspection top level field.",
243+
"locations": [(3, 15)],
244+
}
245+
],
246+
)
247+
248+
def fails_with_introspection_field_in_anonymous_subscription():
249+
assert_errors(
250+
"""
251+
subscription {
252+
__typename
253+
}
254+
""",
255+
[
256+
{
257+
"message": "Anonymous Subscription must not"
258+
" select an introspection top level field.",
259+
"locations": [(3, 15)],
260+
}
261+
],
262+
)
263+
264+
def skips_if_not_subscription_type():
265+
assert_errors(
266+
"""
267+
subscription {
268+
__typename
269+
}
270+
""",
271+
[],
272+
schema=empty_schema,
273+
)

0 commit comments

Comments
 (0)