Skip to content

Commit 8693de1

Browse files
committed
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
1 parent f2070c8 commit 8693de1

File tree

7 files changed

+214
-78
lines changed

7 files changed

+214
-78
lines changed

src/execution/__tests__/executor-test.js

Lines changed: 1 addition & 1 deletion
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',

src/execution/__tests__/variables-test.js

Lines changed: 1 addition & 1 deletion
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

Lines changed: 20 additions & 0 deletions
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

Lines changed: 54 additions & 14 deletions
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,24 @@ 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 an array value: [1]',
114+
);
115+
});
118116

119117
it('serializes output as Boolean', () => {
120118
expect(GraphQLBoolean.serialize('string')).to.equal(true);
@@ -129,4 +127,46 @@ describe('Type System: Scalar coercion', () => {
129127
'Boolean cannot represent an array value: [false]',
130128
);
131129
});
130+
131+
it('serializes output as ID', () => {
132+
expect(GraphQLID.serialize('string')).to.equal('string');
133+
expect(GraphQLID.serialize('false')).to.equal('false');
134+
expect(GraphQLID.serialize('')).to.equal('');
135+
expect(GraphQLID.serialize(123)).to.equal('123');
136+
expect(GraphQLID.serialize(0)).to.equal('0');
137+
138+
const objValue = {
139+
_id: 123,
140+
valueOf() {
141+
return this._id;
142+
},
143+
};
144+
expect(GraphQLID.serialize(objValue)).to.equal('123');
145+
146+
const badObjValue = {
147+
_id: false,
148+
valueOf() {
149+
return this._id;
150+
},
151+
};
152+
expect(() => GraphQLID.serialize(badObjValue)).to.throw(
153+
'ID cannot represent value: {{_id: false, valueOf: [Function]}}',
154+
);
155+
156+
expect(() => GraphQLID.serialize(true)).to.throw(
157+
'ID cannot represent value: true',
158+
);
159+
160+
expect(() => GraphQLID.serialize(-1.1)).to.throw(
161+
'ID cannot represent value: -1.1',
162+
);
163+
164+
expect(() => GraphQLID.serialize({})).to.throw(
165+
'ID cannot represent value: {}',
166+
);
167+
168+
expect(() => GraphQLID.serialize(['abc'])).to.throw(
169+
'ID cannot represent value: ["abc"]',
170+
);
171+
});
132172
});

src/type/scalars.js

Lines changed: 85 additions & 29 deletions
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,7 +112,7 @@ export const GraphQLFloat = new GraphQLScalarType({
99112
},
100113
});
101114

102-
function coerceString(value: mixed): string {
115+
function serializeString(value: mixed): string {
103116
if (Array.isArray(value)) {
104117
throw new TypeError(
105118
`String cannot represent an array value: ${inspect(value)}`,
@@ -108,38 +121,81 @@ function coerceString(value: mixed): string {
108121
return String(value);
109122
}
110123

124+
function coerceString(value: mixed): string {
125+
if (typeof value !== 'string') {
126+
throw new TypeError(
127+
`String cannot represent a non string value: ${inspect(value)}`,
128+
);
129+
}
130+
return value;
131+
}
132+
111133
export const GraphQLString = new GraphQLScalarType({
112134
name: 'String',
113135
description:
114136
'The `String` scalar type represents textual data, represented as UTF-8 ' +
115137
'character sequences. The String type is most often used by GraphQL to ' +
116138
'represent free-form human-readable text.',
117-
serialize: coerceString,
139+
serialize: serializeString,
118140
parseValue: coerceString,
119141
parseLiteral(ast) {
120142
return ast.kind === Kind.STRING ? ast.value : undefined;
121143
},
122144
});
123145

124-
function coerceBoolean(value: mixed): boolean {
146+
function serializeBoolean(value: mixed): boolean {
125147
if (Array.isArray(value)) {
126148
throw new TypeError(
127-
`Boolean cannot represent an array value: [${String(value)}]`,
149+
`Boolean cannot represent an array value: ${inspect(value)}`,
128150
);
129151
}
130152
return Boolean(value);
131153
}
132154

155+
function coerceBoolean(value: mixed): boolean {
156+
if (typeof value !== 'boolean') {
157+
throw new TypeError(
158+
`Boolean cannot represent a non boolean value: ${inspect(value)}`,
159+
);
160+
}
161+
return value;
162+
}
163+
133164
export const GraphQLBoolean = new GraphQLScalarType({
134165
name: 'Boolean',
135166
description: 'The `Boolean` scalar type represents `true` or `false`.',
136-
serialize: coerceBoolean,
167+
serialize: serializeBoolean,
137168
parseValue: coerceBoolean,
138169
parseLiteral(ast) {
139170
return ast.kind === Kind.BOOLEAN ? ast.value : undefined;
140171
},
141172
});
142173

174+
function serializeID(value: mixed): string {
175+
// Support serializing objects with custom valueOf() functions - a common way
176+
// to represent an object identifier.
177+
const result = value && value.valueOf !== Object.prototype.valueOf
178+
? value.valueOf()
179+
: value;
180+
if (
181+
typeof result !== 'string' &&
182+
(typeof result !== 'number' || !isInteger(result))
183+
) {
184+
throw new TypeError(`ID cannot represent value: ${inspect(value)}`);
185+
}
186+
return String(result);
187+
}
188+
189+
function coerceID(value: mixed): string {
190+
if (
191+
typeof value !== 'string' &&
192+
(typeof value !== 'number' || !isInteger(value))
193+
) {
194+
throw new TypeError(`ID cannot represent value: ${inspect(value)}`);
195+
}
196+
return String(value);
197+
}
198+
143199
export const GraphQLID = new GraphQLScalarType({
144200
name: 'ID',
145201
description:
@@ -148,8 +204,8 @@ export const GraphQLID = new GraphQLScalarType({
148204
'response as a String; however, it is not intended to be human-readable. ' +
149205
'When expected as an input type, any string (such as `"4"`) or integer ' +
150206
'(such as `4`) input value will be accepted as an ID.',
151-
serialize: coerceString,
152-
parseValue: coerceString,
207+
serialize: serializeID,
208+
parseValue: coerceID,
153209
parseLiteral(ast) {
154210
return ast.kind === Kind.STRING || ast.kind === Kind.INT
155211
? ast.value

src/utilities/__tests__/astFromValue-test.js

Lines changed: 3 additions & 4 deletions
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)