Skip to content

Commit d5eac89

Browse files
authored
RFC: Assert subscription field is not introspection. (#2861)
1 parent fd3d8c9 commit d5eac89

File tree

3 files changed

+289
-14
lines changed

3 files changed

+289
-14
lines changed

src/validation/__tests__/SingleFieldSubscriptionsRule-test.ts

Lines changed: 188 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import { describe, it } from 'mocha';
22

33
import { SingleFieldSubscriptionsRule } from '../rules/SingleFieldSubscriptionsRule';
44

5-
import { expectValidationErrors } from './harness';
5+
import {
6+
expectValidationErrors,
7+
expectValidationErrorsWithSchema,
8+
emptySchema,
9+
} from './harness';
610

711
function expectErrors(queryStr: string) {
812
return expectValidationErrors(SingleFieldSubscriptionsRule, queryStr);
@@ -21,6 +25,41 @@ describe('Validate: Subscriptions with single field', () => {
2125
`);
2226
});
2327

28+
it('valid subscription with fragment', () => {
29+
// From https://spec.graphql.org/draft/#example-13061
30+
expectValid(`
31+
subscription sub {
32+
...newMessageFields
33+
}
34+
35+
fragment newMessageFields on SubscriptionRoot {
36+
newMessage {
37+
body
38+
sender
39+
}
40+
}
41+
`);
42+
});
43+
44+
it('valid subscription with fragment and field', () => {
45+
// From https://spec.graphql.org/draft/#example-13061
46+
expectValid(`
47+
subscription sub {
48+
newMessage {
49+
body
50+
}
51+
...newMessageFields
52+
}
53+
54+
fragment newMessageFields on SubscriptionRoot {
55+
newMessage {
56+
body
57+
sender
58+
}
59+
}
60+
`);
61+
});
62+
2463
it('fails with more than one root field', () => {
2564
expectErrors(`
2665
subscription ImportantEmails {
@@ -48,6 +87,34 @@ describe('Validate: Subscriptions with single field', () => {
4887
'Subscription "ImportantEmails" must select only one top level field.',
4988
locations: [{ line: 4, column: 9 }],
5089
},
90+
{
91+
message:
92+
'Subscription "ImportantEmails" must not select an introspection top level field.',
93+
locations: [{ line: 4, column: 9 }],
94+
},
95+
]);
96+
});
97+
98+
it('fails with more than one root field including aliased introspection via fragment', () => {
99+
expectErrors(`
100+
subscription ImportantEmails {
101+
importantEmails
102+
...Introspection
103+
}
104+
fragment Introspection on SubscriptionRoot {
105+
typename: __typename
106+
}
107+
`).to.deep.equal([
108+
{
109+
message:
110+
'Subscription "ImportantEmails" must select only one top level field.',
111+
locations: [{ line: 7, column: 9 }],
112+
},
113+
{
114+
message:
115+
'Subscription "ImportantEmails" must not select an introspection top level field.',
116+
locations: [{ line: 7, column: 9 }],
117+
},
51118
]);
52119
});
53120

@@ -70,6 +137,86 @@ describe('Validate: Subscriptions with single field', () => {
70137
]);
71138
});
72139

140+
it('fails with many more than one root field via fragments', () => {
141+
expectErrors(`
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+
`).to.deep.equal([
158+
{
159+
message:
160+
'Subscription "ImportantEmails" must select only one top level field.',
161+
locations: [
162+
{ line: 5, column: 11 },
163+
{ line: 10, column: 9 },
164+
{ line: 11, column: 9 },
165+
{ line: 15, column: 9 },
166+
],
167+
},
168+
]);
169+
});
170+
171+
it('does not infinite loop on recursive fragments', () => {
172+
expectErrors(`
173+
subscription NoInfiniteLoop {
174+
...A
175+
}
176+
fragment A on SubscriptionRoot {
177+
...A
178+
}
179+
`).to.deep.equal([]);
180+
});
181+
182+
it('fails with many more than one root field via fragments (anonymous)', () => {
183+
expectErrors(`
184+
subscription {
185+
importantEmails
186+
... {
187+
more: moreImportantEmails
188+
...NotImportantEmails
189+
}
190+
...NotImportantEmails
191+
}
192+
fragment NotImportantEmails on SubscriptionRoot {
193+
notImportantEmails
194+
deleted: deletedEmails
195+
... {
196+
... {
197+
archivedEmails
198+
}
199+
}
200+
...SpamEmails
201+
}
202+
fragment SpamEmails on SubscriptionRoot {
203+
spamEmails
204+
...NonExistentFragment
205+
}
206+
`).to.deep.equal([
207+
{
208+
message: 'Anonymous Subscription must select only one top level field.',
209+
locations: [
210+
{ line: 5, column: 11 },
211+
{ line: 11, column: 9 },
212+
{ line: 12, column: 9 },
213+
{ line: 15, column: 13 },
214+
{ line: 21, column: 9 },
215+
],
216+
},
217+
]);
218+
});
219+
73220
it('fails with more than one root field in anonymous subscriptions', () => {
74221
expectErrors(`
75222
subscription {
@@ -83,4 +230,44 @@ describe('Validate: Subscriptions with single field', () => {
83230
},
84231
]);
85232
});
233+
234+
it('fails with introspection field', () => {
235+
expectErrors(`
236+
subscription ImportantEmails {
237+
__typename
238+
}
239+
`).to.deep.equal([
240+
{
241+
message:
242+
'Subscription "ImportantEmails" must not select an introspection top level field.',
243+
locations: [{ line: 3, column: 9 }],
244+
},
245+
]);
246+
});
247+
248+
it('fails with introspection field in anonymous subscription', () => {
249+
expectErrors(`
250+
subscription {
251+
__typename
252+
}
253+
`).to.deep.equal([
254+
{
255+
message:
256+
'Anonymous Subscription must not select an introspection top level field.',
257+
locations: [{ line: 3, column: 9 }],
258+
},
259+
]);
260+
});
261+
262+
it('skips if not subscription type', () => {
263+
expectValidationErrorsWithSchema(
264+
emptySchema,
265+
SingleFieldSubscriptionsRule,
266+
`
267+
subscription {
268+
__typename
269+
}
270+
`,
271+
).to.deep.equal([]);
272+
});
86273
});

src/validation/__tests__/harness.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,23 @@ export const testSchema: GraphQLSchema = buildSchema(`
130130
complicatedArgs: ComplicatedArgs
131131
}
132132
133+
type Message {
134+
body: String
135+
sender: String
136+
}
137+
138+
type SubscriptionRoot {
139+
importantEmails: [String]
140+
notImportantEmails: [String]
141+
moreImportantEmails: [String]
142+
spamEmails: [String]
143+
deletedEmails: [String]
144+
newMessage: Message
145+
}
146+
133147
schema {
134148
query: QueryRoot
149+
subscription: SubscriptionRoot
135150
}
136151
137152
directive @onQuery on QUERY
@@ -144,6 +159,16 @@ export const testSchema: GraphQLSchema = buildSchema(`
144159
directive @onVariableDefinition on VARIABLE_DEFINITION
145160
`);
146161

162+
export const emptySchema: GraphQLSchema = buildSchema(`
163+
type QueryRoot {
164+
empty: Boolean
165+
}
166+
167+
schema {
168+
query: QueryRoot
169+
}
170+
`);
171+
147172
export function expectValidationErrorsWithSchema(
148173
schema: GraphQLSchema,
149174
rule: ValidationRule,

src/validation/rules/SingleFieldSubscriptionsRule.ts

Lines changed: 76 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,93 @@
1+
import type { ObjMap } from '../../jsutils/ObjMap';
12
import { GraphQLError } from '../../error/GraphQLError';
23

34
import type { ASTVisitor } from '../../language/visitor';
4-
import type { OperationDefinitionNode } from '../../language/ast';
5+
import type {
6+
OperationDefinitionNode,
7+
FragmentDefinitionNode,
8+
} from '../../language/ast';
9+
import { Kind } from '../../language/kinds';
510

6-
import type { ASTValidationContext } from '../ValidationContext';
11+
import type { ValidationContext } from '../ValidationContext';
12+
import type { ExecutionContext } from '../../execution/execute';
13+
import {
14+
collectFields,
15+
defaultFieldResolver,
16+
defaultTypeResolver,
17+
} from '../../execution/execute';
718

819
/**
9-
* Subscriptions must only include one field.
20+
* Subscriptions must only include a non-introspection field.
1021
*
11-
* A GraphQL subscription is valid only if it contains a single root field.
22+
* A GraphQL subscription is valid only if it contains a single root field and
23+
* that root field is not an introspection field.
1224
*/
1325
export function SingleFieldSubscriptionsRule(
14-
context: ASTValidationContext,
26+
context: ValidationContext,
1527
): ASTVisitor {
1628
return {
1729
OperationDefinition(node: OperationDefinitionNode) {
1830
if (node.operation === 'subscription') {
19-
if (node.selectionSet.selections.length !== 1) {
20-
context.reportError(
21-
new GraphQLError(
22-
node.name
23-
? `Subscription "${node.name.value}" must select only one top level field.`
24-
: 'Anonymous Subscription must select only one top level field.',
25-
node.selectionSet.selections.slice(1),
26-
),
31+
const schema = context.getSchema();
32+
const subscriptionType = schema.getSubscriptionType();
33+
if (subscriptionType) {
34+
const operationName = node.name ? node.name.value : null;
35+
const variableValues: {
36+
[variable: string]: any;
37+
} = Object.create(null);
38+
const document = context.getDocument();
39+
const fragments: ObjMap<FragmentDefinitionNode> = Object.create(null);
40+
for (const definition of document.definitions) {
41+
if (definition.kind === Kind.FRAGMENT_DEFINITION) {
42+
fragments[definition.name.value] = definition;
43+
}
44+
}
45+
// FIXME: refactor out `collectFields` into utility function that doesn't need fake context.
46+
const fakeExecutionContext: ExecutionContext = {
47+
schema,
48+
fragments,
49+
rootValue: undefined,
50+
contextValue: undefined,
51+
operation: node,
52+
variableValues,
53+
fieldResolver: defaultFieldResolver,
54+
typeResolver: defaultTypeResolver,
55+
errors: [],
56+
};
57+
const fields = collectFields(
58+
fakeExecutionContext,
59+
subscriptionType,
60+
node.selectionSet,
61+
new Map(),
62+
new Set(),
2763
);
64+
if (fields.size > 1) {
65+
const fieldSelectionLists = [...fields.values()];
66+
const extraFieldSelectionLists = fieldSelectionLists.slice(1);
67+
const extraFieldSelections = extraFieldSelectionLists.flat();
68+
context.reportError(
69+
new GraphQLError(
70+
operationName != null
71+
? `Subscription "${operationName}" must select only one top level field.`
72+
: 'Anonymous Subscription must select only one top level field.',
73+
extraFieldSelections,
74+
),
75+
);
76+
}
77+
for (const fieldNodes of fields.values()) {
78+
const field = fieldNodes[0];
79+
const fieldName = field.name.value;
80+
if (fieldName[0] === '_' && fieldName[1] === '_') {
81+
context.reportError(
82+
new GraphQLError(
83+
operationName != null
84+
? `Subscription "${operationName}" must not select an introspection top level field.`
85+
: 'Anonymous Subscription must not select an introspection top level field.',
86+
fieldNodes,
87+
),
88+
);
89+
}
90+
}
2891
}
2992
}
3093
},

0 commit comments

Comments
 (0)