Skip to content

Commit 3521e14

Browse files
authored
BREAKING/BUGFIX Strict coercion of scalar types (#1382)
* BREAKING/BUGFIX Strict coercion of scalar types This no longer accepts incoming variable values in a potentially lossy way, mirroring the existing behavior for literals. This fixes an issue with GraphQL.js being not spec compliant. This is breaking since servers which used to accept incorrect variable values will now return errors to clients. Serialization of values is not affected in the same way, since this is not a client-visible behavior. As a bonus, this adds unique serialization and coercion functions for the ID type, allowing to be more restrictive on numeric types and un-stringable object types, while directly supporting valueOf() methods (ala MongoDB). The changes to how the ID type serializes and coerces data could be potentially breaking. Fixes #1324 * Updates from review. Simplified ID serialization and added similar logic to string serialization
1 parent 626b7a9 commit 3521e14

File tree

7 files changed

+239
-81
lines changed

7 files changed

+239
-81
lines changed

src/execution/__tests__/executor-test.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ describe('Execute: Handles basic execution tasks', () => {
279279

280280
const rootValue = { root: 'val' };
281281

282-
execute(schema, ast, rootValue, null, { var: 123 });
282+
execute(schema, ast, rootValue, null, { var: 'abc' });
283283

284284
expect(Object.keys(info)).to.deep.equal([
285285
'fieldName',
@@ -304,7 +304,7 @@ describe('Execute: Handles basic execution tasks', () => {
304304
expect(info.schema).to.equal(schema);
305305
expect(info.rootValue).to.equal(rootValue);
306306
expect(info.operation).to.equal(ast.definitions[0]);
307-
expect(info.variableValues).to.deep.equal({ var: '123' });
307+
expect(info.variableValues).to.deep.equal({ var: 'abc' });
308308
});
309309

310310
it('threads root value context correctly', () => {

src/execution/__tests__/variables-test.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -694,7 +694,7 @@ describe('Execute: Handles inputs', () => {
694694
{
695695
message:
696696
'Variable "$value" got invalid value [1, 2, 3]; Expected type ' +
697-
'String; String cannot represent an array value: [1, 2, 3]',
697+
'String; String cannot represent a non string value: [1, 2, 3]',
698698
locations: [{ line: 2, column: 16 }],
699699
},
700700
],

src/jsutils/isFinite.js

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* Copyright (c) 2018-present, Facebook, Inc.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict
8+
*/
9+
10+
declare function isFinite(value: mixed): boolean %checks(typeof value ===
11+
'number');
12+
13+
/* eslint-disable no-redeclare */
14+
// $FlowFixMe workaround for: https://github.com/facebook/flow/issues/4441
15+
const isFinite =
16+
Number.isFinite ||
17+
function(value) {
18+
return typeof value === 'number' && isFinite(value);
19+
};
20+
export default isFinite;

src/type/__tests__/serialization-test.js

+68-14
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ describe('Type System: Scalar coercion', () => {
6060
);
6161
// Doesn't represent number
6262
expect(() => GraphQLInt.serialize('')).to.throw(
63-
'Int cannot represent non-integer value: (empty string)',
63+
'Int cannot represent non-integer value: ""',
6464
);
6565
expect(() => GraphQLInt.serialize(NaN)).to.throw(
6666
'Int cannot represent non-integer value: NaN',
@@ -95,26 +95,38 @@ describe('Type System: Scalar coercion', () => {
9595
'Float cannot represent non numeric value: "one"',
9696
);
9797
expect(() => GraphQLFloat.serialize('')).to.throw(
98-
'Float cannot represent non numeric value: (empty string)',
98+
'Float cannot represent non numeric value: ""',
9999
);
100100
expect(() => GraphQLFloat.serialize([5])).to.throw(
101101
'Float cannot represent an array value: [5]',
102102
);
103103
});
104104

105-
for (const scalar of [GraphQLString, GraphQLID]) {
106-
it(`serializes output as ${scalar}`, () => {
107-
expect(scalar.serialize('string')).to.equal('string');
108-
expect(scalar.serialize(1)).to.equal('1');
109-
expect(scalar.serialize(-1.1)).to.equal('-1.1');
110-
expect(scalar.serialize(true)).to.equal('true');
111-
expect(scalar.serialize(false)).to.equal('false');
105+
it(`serializes output as String`, () => {
106+
expect(GraphQLString.serialize('string')).to.equal('string');
107+
expect(GraphQLString.serialize(1)).to.equal('1');
108+
expect(GraphQLString.serialize(-1.1)).to.equal('-1.1');
109+
expect(GraphQLString.serialize(true)).to.equal('true');
110+
expect(GraphQLString.serialize(false)).to.equal('false');
112111

113-
expect(() => scalar.serialize([1])).to.throw(
114-
'String cannot represent an array value: [1]',
115-
);
116-
});
117-
}
112+
expect(() => GraphQLString.serialize([1])).to.throw(
113+
'String cannot represent value: [1]',
114+
);
115+
116+
const badObjValue = {};
117+
expect(() => GraphQLString.serialize(badObjValue)).to.throw(
118+
'String cannot represent value: {}',
119+
);
120+
121+
const stringableObjValue = {
122+
valueOf() {
123+
return 'something useful';
124+
},
125+
};
126+
expect(GraphQLString.serialize(stringableObjValue)).to.equal(
127+
'something useful',
128+
);
129+
});
118130

119131
it('serializes output as Boolean', () => {
120132
expect(GraphQLBoolean.serialize('string')).to.equal(true);
@@ -129,4 +141,46 @@ describe('Type System: Scalar coercion', () => {
129141
'Boolean cannot represent an array value: [false]',
130142
);
131143
});
144+
145+
it('serializes output as ID', () => {
146+
expect(GraphQLID.serialize('string')).to.equal('string');
147+
expect(GraphQLID.serialize('false')).to.equal('false');
148+
expect(GraphQLID.serialize('')).to.equal('');
149+
expect(GraphQLID.serialize(123)).to.equal('123');
150+
expect(GraphQLID.serialize(0)).to.equal('0');
151+
152+
const objValue = {
153+
_id: 123,
154+
valueOf() {
155+
return this._id;
156+
},
157+
};
158+
expect(GraphQLID.serialize(objValue)).to.equal('123');
159+
160+
const badObjValue = {
161+
_id: false,
162+
valueOf() {
163+
return this._id;
164+
},
165+
};
166+
expect(() => GraphQLID.serialize(badObjValue)).to.throw(
167+
'ID cannot represent value: {_id: false, valueOf: [function valueOf]}',
168+
);
169+
170+
expect(() => GraphQLID.serialize(true)).to.throw(
171+
'ID cannot represent value: true',
172+
);
173+
174+
expect(() => GraphQLID.serialize(-1.1)).to.throw(
175+
'ID cannot represent value: -1.1',
176+
);
177+
178+
expect(() => GraphQLID.serialize({})).to.throw(
179+
'ID cannot represent value: {}',
180+
);
181+
182+
expect(() => GraphQLID.serialize(['abc'])).to.throw(
183+
'ID cannot represent value: ["abc"]',
184+
);
185+
});
132186
});

src/type/scalars.js

+95-31
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
*/
99

1010
import inspect from '../jsutils/inspect';
11+
import isFinite from '../jsutils/isFinite';
1112
import isInteger from '../jsutils/isInteger';
1213
import { GraphQLScalarType, isNamedType } from './definition';
1314
import { Kind } from '../language/kinds';
@@ -20,38 +21,46 @@ import { Kind } from '../language/kinds';
2021
const MAX_INT = 2147483647;
2122
const MIN_INT = -2147483648;
2223

23-
function coerceInt(value: mixed): number {
24+
function serializeInt(value: mixed): number {
2425
if (Array.isArray(value)) {
2526
throw new TypeError(
26-
`Int cannot represent an array value: [${String(value)}]`,
27+
`Int cannot represent an array value: ${inspect(value)}`,
2728
);
2829
}
29-
if (value === '') {
30+
const num = Number(value);
31+
if (value === '' || !isInteger(num)) {
3032
throw new TypeError(
31-
'Int cannot represent non-integer value: (empty string)',
33+
`Int cannot represent non-integer value: ${inspect(value)}`,
3234
);
3335
}
34-
const num = Number(value);
35-
if (!isInteger(num)) {
36+
if (num > MAX_INT || num < MIN_INT) {
3637
throw new TypeError(
37-
'Int cannot represent non-integer value: ' + inspect(value),
38+
`Int cannot represent non 32-bit signed integer value: ${inspect(value)}`,
3839
);
3940
}
41+
return num;
42+
}
4043

41-
if (num > MAX_INT || num < MIN_INT) {
44+
function coerceInt(value: mixed): number {
45+
if (!isInteger(value)) {
4246
throw new TypeError(
43-
'Int cannot represent non 32-bit signed integer value: ' + inspect(value),
47+
`Int cannot represent non-integer value: ${inspect(value)}`,
4448
);
4549
}
46-
return num;
50+
if (value > MAX_INT || value < MIN_INT) {
51+
throw new TypeError(
52+
`Int cannot represent non 32-bit signed integer value: ${inspect(value)}`,
53+
);
54+
}
55+
return value;
4756
}
4857

4958
export const GraphQLInt = new GraphQLScalarType({
5059
name: 'Int',
5160
description:
5261
'The `Int` scalar type represents non-fractional signed whole numeric ' +
5362
'values. Int can represent values between -(2^31) and 2^31 - 1. ',
54-
serialize: coerceInt,
63+
serialize: serializeInt,
5564
parseValue: coerceInt,
5665
parseLiteral(ast) {
5766
if (ast.kind === Kind.INT) {
@@ -64,24 +73,28 @@ export const GraphQLInt = new GraphQLScalarType({
6473
},
6574
});
6675

67-
function coerceFloat(value: mixed): number {
76+
function serializeFloat(value: mixed): number {
6877
if (Array.isArray(value)) {
6978
throw new TypeError(
70-
`Float cannot represent an array value: [${String(value)}]`,
79+
`Float cannot represent an array value: ${inspect(value)}`,
7180
);
7281
}
73-
if (value === '') {
82+
const num = Number(value);
83+
if (value === '' || !isFinite(num)) {
7484
throw new TypeError(
75-
'Float cannot represent non numeric value: (empty string)',
85+
`Float cannot represent non numeric value: ${inspect(value)}`,
7686
);
7787
}
78-
const num = Number(value);
79-
if (isFinite(num)) {
80-
return num;
88+
return num;
89+
}
90+
91+
function coerceFloat(value: mixed): number {
92+
if (!isFinite(value)) {
93+
throw new TypeError(
94+
`Float cannot represent non numeric value: ${inspect(value)}`,
95+
);
8196
}
82-
throw new TypeError(
83-
'Float cannot represent non numeric value: ' + inspect(value),
84-
);
97+
return value;
8598
}
8699

87100
export const GraphQLFloat = new GraphQLScalarType({
@@ -90,7 +103,7 @@ export const GraphQLFloat = new GraphQLScalarType({
90103
'The `Float` scalar type represents signed double-precision fractional ' +
91104
'values as specified by ' +
92105
'[IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point). ',
93-
serialize: coerceFloat,
106+
serialize: serializeFloat,
94107
parseValue: coerceFloat,
95108
parseLiteral(ast) {
96109
return ast.kind === Kind.FLOAT || ast.kind === Kind.INT
@@ -99,13 +112,31 @@ export const GraphQLFloat = new GraphQLScalarType({
99112
},
100113
});
101114

115+
function serializeString(value: mixed): string {
116+
// Support serializing objects with custom valueOf() functions - a common way
117+
// to represent an complex value which can be represented as a string
118+
// (ex: MongoDB id objects).
119+
const result =
120+
value && typeof value.valueOf === 'function' ? value.valueOf() : value;
121+
// Serialize string, number, and boolean values to a string, but do not
122+
// attempt to coerce object, function, symbol, or other types as strings.
123+
if (
124+
typeof result !== 'string' &&
125+
typeof result !== 'number' &&
126+
typeof result !== 'boolean'
127+
) {
128+
throw new TypeError(`String cannot represent value: ${inspect(result)}`);
129+
}
130+
return String(result);
131+
}
132+
102133
function coerceString(value: mixed): string {
103-
if (Array.isArray(value)) {
134+
if (typeof value !== 'string') {
104135
throw new TypeError(
105-
`String cannot represent an array value: ${inspect(value)}`,
136+
`String cannot represent a non string value: ${inspect(value)}`,
106137
);
107138
}
108-
return String(value);
139+
return value;
109140
}
110141

111142
export const GraphQLString = new GraphQLScalarType({
@@ -114,32 +145,65 @@ export const GraphQLString = new GraphQLScalarType({
114145
'The `String` scalar type represents textual data, represented as UTF-8 ' +
115146
'character sequences. The String type is most often used by GraphQL to ' +
116147
'represent free-form human-readable text.',
117-
serialize: coerceString,
148+
serialize: serializeString,
118149
parseValue: coerceString,
119150
parseLiteral(ast) {
120151
return ast.kind === Kind.STRING ? ast.value : undefined;
121152
},
122153
});
123154

124-
function coerceBoolean(value: mixed): boolean {
155+
function serializeBoolean(value: mixed): boolean {
125156
if (Array.isArray(value)) {
126157
throw new TypeError(
127-
`Boolean cannot represent an array value: [${String(value)}]`,
158+
`Boolean cannot represent an array value: ${inspect(value)}`,
128159
);
129160
}
130161
return Boolean(value);
131162
}
132163

164+
function coerceBoolean(value: mixed): boolean {
165+
if (typeof value !== 'boolean') {
166+
throw new TypeError(
167+
`Boolean cannot represent a non boolean value: ${inspect(value)}`,
168+
);
169+
}
170+
return value;
171+
}
172+
133173
export const GraphQLBoolean = new GraphQLScalarType({
134174
name: 'Boolean',
135175
description: 'The `Boolean` scalar type represents `true` or `false`.',
136-
serialize: coerceBoolean,
176+
serialize: serializeBoolean,
137177
parseValue: coerceBoolean,
138178
parseLiteral(ast) {
139179
return ast.kind === Kind.BOOLEAN ? ast.value : undefined;
140180
},
141181
});
142182

183+
function serializeID(value: mixed): string {
184+
// Support serializing objects with custom valueOf() functions - a common way
185+
// to represent an object identifier (ex. MongoDB).
186+
const result =
187+
value && typeof value.valueOf === 'function' ? value.valueOf() : value;
188+
if (
189+
typeof result !== 'string' &&
190+
(typeof result !== 'number' || !isInteger(result))
191+
) {
192+
throw new TypeError(`ID cannot represent value: ${inspect(value)}`);
193+
}
194+
return String(result);
195+
}
196+
197+
function coerceID(value: mixed): string {
198+
if (
199+
typeof value !== 'string' &&
200+
(typeof value !== 'number' || !isInteger(value))
201+
) {
202+
throw new TypeError(`ID cannot represent value: ${inspect(value)}`);
203+
}
204+
return String(value);
205+
}
206+
143207
export const GraphQLID = new GraphQLScalarType({
144208
name: 'ID',
145209
description:
@@ -148,8 +212,8 @@ export const GraphQLID = new GraphQLScalarType({
148212
'response as a String; however, it is not intended to be human-readable. ' +
149213
'When expected as an input type, any string (such as `"4"`) or integer ' +
150214
'(such as `4`) input value will be accepted as an ID.',
151-
serialize: coerceString,
152-
parseValue: coerceString,
215+
serialize: serializeID,
216+
parseValue: coerceID,
153217
parseLiteral(ast) {
154218
return ast.kind === Kind.STRING || ast.kind === Kind.INT
155219
? ast.value

src/utilities/__tests__/astFromValue-test.js

+3-4
Original file line numberDiff line numberDiff line change
@@ -183,10 +183,9 @@ describe('astFromValue', () => {
183183
value: '01',
184184
});
185185

186-
expect(astFromValue(false, GraphQLID)).to.deep.equal({
187-
kind: 'StringValue',
188-
value: 'false',
189-
});
186+
expect(() => astFromValue(false, GraphQLID)).to.throw(
187+
'ID cannot represent value: false',
188+
);
190189

191190
expect(astFromValue(null, GraphQLID)).to.deep.equal({ kind: 'NullValue' });
192191

0 commit comments

Comments
 (0)