Skip to content

Commit 15e39f5

Browse files
committed
Validate oneOf input objects at execution time
This ensures that we enforce the rules for OneOf Input Objects at execution time.
1 parent 24f0d95 commit 15e39f5

File tree

5 files changed

+294
-0
lines changed

5 files changed

+294
-0
lines changed

src/execution/__tests__/oneof-test.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { describe, it } from 'mocha';
2+
3+
import { expectJSON } from '../../__testUtils__/expectJSON';
4+
5+
import { parse } from '../../language/parser';
6+
7+
import { buildSchema } from '../../utilities/buildASTSchema';
8+
9+
import type { ExecutionResult } from '../execute';
10+
import { execute } from '../execute';
11+
12+
const schema = buildSchema(`
13+
type Query {
14+
test(input: TestInputObject!): TestObject
15+
}
16+
17+
input TestInputObject @oneOf {
18+
a: String
19+
b: Int
20+
}
21+
22+
type TestObject @oneOf {
23+
a: String
24+
b: Int
25+
}
26+
27+
schema {
28+
query: Query
29+
}
30+
`);
31+
32+
function executeQuery(
33+
query: string,
34+
rootValue: unknown,
35+
variableValues?: { [variable: string]: unknown },
36+
): ExecutionResult | Promise<ExecutionResult> {
37+
return execute({ schema, document: parse(query), rootValue, variableValues });
38+
}
39+
40+
describe('Execute: Handles Oneof Input Objects and Oneof Objects', () => {
41+
describe('Oneof Input Objects', () => {
42+
const rootValue = {
43+
test({ input }: { input: { a?: string; b?: number } }) {
44+
return input;
45+
},
46+
};
47+
48+
it('accepts a good default value', () => {
49+
const query = `
50+
query ($input: TestInputObject! = {a: "abc"}) {
51+
test(input: $input) {
52+
a
53+
b
54+
}
55+
}
56+
`;
57+
const result = executeQuery(query, rootValue);
58+
59+
expectJSON(result).toDeepEqual({
60+
data: {
61+
test: {
62+
a: 'abc',
63+
b: null,
64+
},
65+
},
66+
});
67+
});
68+
69+
it('rejects a bad default value', () => {
70+
const query = `
71+
query ($input: TestInputObject! = {a: "abc", b: 123}) {
72+
test(input: $input) {
73+
a
74+
b
75+
}
76+
}
77+
`;
78+
const result = executeQuery(query, rootValue);
79+
80+
expectJSON(result).toDeepEqual({
81+
data: {
82+
test: null,
83+
},
84+
errors: [
85+
{
86+
locations: [{ column: 23, line: 3 }],
87+
message:
88+
'Argument "input" of non-null type "TestInputObject!" must not be null.',
89+
path: ['test'],
90+
},
91+
],
92+
});
93+
});
94+
95+
it('accepts a good variable', () => {
96+
const query = `
97+
query ($input: TestInputObject!) {
98+
test(input: $input) {
99+
a
100+
b
101+
}
102+
}
103+
`;
104+
const result = executeQuery(query, rootValue, { input: { a: 'abc' } });
105+
106+
expectJSON(result).toDeepEqual({
107+
data: {
108+
test: {
109+
a: 'abc',
110+
b: null,
111+
},
112+
},
113+
});
114+
});
115+
116+
it('rejects a bad variable', () => {
117+
const query = `
118+
query ($input: TestInputObject!) {
119+
test(input: $input) {
120+
a
121+
b
122+
}
123+
}
124+
`;
125+
const result = executeQuery(query, rootValue, {
126+
input: { a: 'abc', b: 123 },
127+
});
128+
129+
expectJSON(result).toDeepEqual({
130+
errors: [
131+
{
132+
locations: [{ column: 16, line: 2 }],
133+
message:
134+
'Variable "$input" got invalid value { a: "abc", b: 123 }; Exactly one key must be specified.',
135+
},
136+
],
137+
});
138+
});
139+
});
140+
});

src/utilities/__tests__/coerceInputValue-test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,108 @@ describe('coerceInputValue', () => {
273273
});
274274
});
275275

276+
describe('for GraphQLInputObject that isOneOf', () => {
277+
const TestInputObject = new GraphQLInputObjectType({
278+
name: 'TestInputObject',
279+
fields: {
280+
foo: { type: GraphQLInt },
281+
bar: { type: GraphQLInt },
282+
},
283+
isOneOf: true,
284+
});
285+
286+
it('returns no error for a valid input', () => {
287+
const result = coerceValue({ foo: 123 }, TestInputObject);
288+
expectValue(result).to.deep.equal({ foo: 123 });
289+
});
290+
291+
it('returns an error if more than one field is specified', () => {
292+
const result = coerceValue({ foo: 123, bar: null }, TestInputObject);
293+
expectErrors(result).to.deep.equal([
294+
{
295+
error: 'Exactly one key must be specified.',
296+
path: [],
297+
value: { foo: 123, bar: null },
298+
},
299+
]);
300+
});
301+
302+
it('returns an error the one field is null', () => {
303+
const result = coerceValue({ bar: null }, TestInputObject);
304+
expectErrors(result).to.deep.equal([
305+
{
306+
error: 'Field "bar" must be non-null.',
307+
path: ['bar'],
308+
value: null,
309+
},
310+
]);
311+
});
312+
313+
it('returns an error for an invalid field', () => {
314+
const result = coerceValue({ foo: NaN }, TestInputObject);
315+
expectErrors(result).to.deep.equal([
316+
{
317+
error: 'Int cannot represent non-integer value: NaN',
318+
path: ['foo'],
319+
value: NaN,
320+
},
321+
]);
322+
});
323+
324+
it('returns multiple errors for multiple invalid fields', () => {
325+
const result = coerceValue({ foo: 'abc', bar: 'def' }, TestInputObject);
326+
expectErrors(result).to.deep.equal([
327+
{
328+
error: 'Int cannot represent non-integer value: "abc"',
329+
path: ['foo'],
330+
value: 'abc',
331+
},
332+
{
333+
error: 'Int cannot represent non-integer value: "def"',
334+
path: ['bar'],
335+
value: 'def',
336+
},
337+
{
338+
error: 'Exactly one key must be specified.',
339+
path: [],
340+
value: { foo: 'abc', bar: 'def' },
341+
},
342+
]);
343+
});
344+
345+
it('returns error for an unknown field', () => {
346+
const result = coerceValue(
347+
{ foo: 123, unknownField: 123 },
348+
TestInputObject,
349+
);
350+
expectErrors(result).to.deep.equal([
351+
{
352+
error:
353+
'Field "unknownField" is not defined by type "TestInputObject".',
354+
path: [],
355+
value: { foo: 123, unknownField: 123 },
356+
},
357+
]);
358+
});
359+
360+
it('returns error for a misspelled field', () => {
361+
const result = coerceValue({ bart: 123 }, TestInputObject);
362+
expectErrors(result).to.deep.equal([
363+
{
364+
error:
365+
'Field "bart" is not defined by type "TestInputObject". Did you mean "bar"?',
366+
path: [],
367+
value: { bart: 123 },
368+
},
369+
{
370+
error: 'Exactly one key must be specified.',
371+
path: [],
372+
value: { bart: 123 },
373+
},
374+
]);
375+
});
376+
});
377+
276378
describe('for GraphQLInputObject with default value', () => {
277379
const makeTestInputObject = (defaultValue: any) =>
278380
new GraphQLInputObjectType({

src/utilities/__tests__/valueFromAST-test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,14 @@ describe('valueFromAST', () => {
196196
requiredBool: { type: nonNullBool },
197197
},
198198
});
199+
const testOneOfInputObj = new GraphQLInputObjectType({
200+
name: 'TestOneOfInput',
201+
fields: {
202+
a: { type: GraphQLString },
203+
b: { type: GraphQLString },
204+
},
205+
isOneOf: true,
206+
});
199207

200208
it('coerces input objects according to input coercion rules', () => {
201209
expectValueFrom('null', testInputObj).to.equal(null);
@@ -221,6 +229,16 @@ describe('valueFromAST', () => {
221229
);
222230
expectValueFrom('{ requiredBool: null }', testInputObj).to.equal(undefined);
223231
expectValueFrom('{ bool: true }', testInputObj).to.equal(undefined);
232+
expectValueFrom('{ a: "abc" }', testOneOfInputObj).to.deep.equal({
233+
a: 'abc',
234+
});
235+
expectValueFrom('{ a: null }', testOneOfInputObj).to.equal(undefined);
236+
expectValueFrom('{ a: 1 }', testOneOfInputObj).to.equal(undefined);
237+
expectValueFrom('{ a: "abc", b: "def" }', testOneOfInputObj).to.equal(
238+
undefined,
239+
);
240+
expectValueFrom('{}', testOneOfInputObj).to.equal(undefined);
241+
expectValueFrom('{ c: "abc" }', testOneOfInputObj).to.equal(undefined);
224242
});
225243

226244
it('accepts variable values assuming already coerced', () => {

src/utilities/coerceInputValue.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,28 @@ function coerceInputValueImpl(
142142
);
143143
}
144144
}
145+
146+
if (type.isOneOf) {
147+
const keys = Object.keys(coercedValue);
148+
if (keys.length !== 1) {
149+
onError(
150+
pathToArray(path),
151+
inputValue,
152+
new GraphQLError('Exactly one key must be specified.'),
153+
);
154+
}
155+
156+
const key = keys[0];
157+
const value = coercedValue[key];
158+
if (value === null) {
159+
onError(
160+
pathToArray(path).concat(key),
161+
value,
162+
new GraphQLError(`Field "${key}" must be non-null.`),
163+
);
164+
}
165+
}
166+
145167
return coercedValue;
146168
}
147169

src/utilities/valueFromAST.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,18 @@ export function valueFromAST(
125125
}
126126
coercedObj[field.name] = fieldValue;
127127
}
128+
129+
if (type.isOneOf) {
130+
const keys = Object.keys(coercedObj);
131+
if (keys.length !== 1) {
132+
return; // Invalid: not exactly one key, intentionally return no value.
133+
}
134+
135+
if (coercedObj[keys[0]] === null) {
136+
return; // Invalid: value not non-null, intentionally return no value.
137+
}
138+
}
139+
128140
return coercedObj;
129141
}
130142

0 commit comments

Comments
 (0)