Skip to content

Commit a8ee142

Browse files
committed
Improve Scalar coercion spec compliance
Previously many result coercion failures for built-in scalar types raised Ruby exceptions instead of GraphQL execution errors. The GraphQL spec says that any time a scalar value cannot be coerced to a spec complaint result, an execution error should be raised. Example: https://spec.graphql.org/draft/#sel-GAHXRFHCAACGS6rU This introduces a backwards compatible way to opt into this new spec compliant behaviour. `Schema.spec_compliant_scalar_coercion_errors` controls this behaviour and setting it to `true` will return execution errors instead of raising Ruby exceptions. `Schema.spec_compliant_scalar_coercion_errors` defaults to being `nil` (disabled) and will produce a warning about the intention to change this default in a future version. To preserve this legacy (and spec non-compliant) behaviour, you can customize the error handling logic in `Schema.type_error`. Note: while this change is non-breaking, the runtime exceptions raised have changed. Now any coercion failure will raise `LegacyScalarCoercionError` instead of scalar specific ones (eg: `IntegerEncodingError`). This also improves scalar result coercion errors and makes them more spec compliant. Before: ```ruby { "errors" => [ { "message" => "Int cannot represent non 32-bit signed integer value: 2147483648", }, ], }, ``` ```ruby { "errors" => [ { "message" => "Int cannot represent non 32-bit signed integer value: 2147483648", "locations" => [{ "line" => 1, "column" => 3 }], "path" => ["someField"], }, ], "data" => { "someField" => nil, } }, ```
1 parent 4646e43 commit a8ee142

File tree

13 files changed

+297
-102
lines changed

13 files changed

+297
-102
lines changed

lib/graphql.rb

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,7 @@ class << self
9494
autoload :CoercionError, "graphql/coercion_error"
9595
autoload :InvalidNameError, "graphql/invalid_name_error"
9696
autoload :IntegerDecodingError, "graphql/integer_decoding_error"
97-
autoload :IntegerEncodingError, "graphql/integer_encoding_error"
98-
autoload :StringEncodingError, "graphql/string_encoding_error"
97+
autoload :LegacyScalarCoercionError, "graphql/legacy_scalar_coercion_error"
9998
autoload :DateEncodingError, "graphql/date_encoding_error"
10099
autoload :DurationEncodingError, "graphql/duration_encoding_error"
101100
autoload :TypeKinds, "graphql/type_kinds"

lib/graphql/execution/interpreter/runtime.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -675,6 +675,8 @@ def continue_field(value, owner_type, field, current_type, ast_node, next_select
675675
when "SCALAR", "ENUM"
676676
r = begin
677677
current_type.coerce_result(value, context)
678+
rescue GraphQL::ExecutionError => ex_err
679+
return continue_value(ex_err, field, is_non_null, ast_node, result_name, selection_result)
678680
rescue StandardError => err
679681
query.handle_or_reraise(err)
680682
end

lib/graphql/integer_encoding_error.rb

Lines changed: 0 additions & 36 deletions
This file was deleted.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# frozen_string_literal: true
2+
3+
module GraphQL
4+
# This error is raised when a scalar type cannot coerce a value to its expected type. It is considered legacy because it's raised as a RuntimeTypeError from `Schema.type_error` when `Schema.spec_compliant_scalar_coercion_errors` is not enabled.
5+
class LegacyScalarCoercionError < GraphQL::RuntimeTypeError
6+
# The value which couldn't be coerced
7+
attr_reader :value
8+
9+
# @return [GraphQL::Schema::Field] The field that returned a type error
10+
attr_reader :field
11+
12+
# @return [Array<String, Integer>] Where the field appeared in the GraphQL response
13+
attr_reader :path
14+
15+
def initialize(message, value:, context:)
16+
@value = value
17+
@field = context[:current_field]
18+
@path = context[:current_path]
19+
20+
super(message)
21+
end
22+
end
23+
end

lib/graphql/schema.rb

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -905,6 +905,18 @@ def error_bubbling(new_error_bubbling = nil)
905905

906906
attr_writer :error_bubbling
907907

908+
def spec_compliant_scalar_coercion_errors(new_spec_compliant_scalar_coercion_errors = NOT_CONFIGURED)
909+
if NOT_CONFIGURED.equal?(new_spec_compliant_scalar_coercion_errors)
910+
if defined?(@spec_compliant_scalar_coercion_errors)
911+
@spec_compliant_scalar_coercion_errors
912+
else
913+
find_inherited_value(:spec_compliant_scalar_coercion_errors, false)
914+
end
915+
else
916+
@spec_compliant_scalar_coercion_errors = new_spec_compliant_scalar_coercion_errors
917+
end
918+
end
919+
908920
attr_writer :max_depth
909921

910922
def max_depth(new_max_depth = nil, count_introspection_fields: true)
@@ -1313,11 +1325,24 @@ def type_error(type_error, context)
13131325
execution_error = GraphQL::ExecutionError.new(type_error.message, ast_node: type_error.ast_node)
13141326
execution_error.path = context[:current_path]
13151327

1316-
context.errors << execution_error
1317-
when GraphQL::UnresolvedTypeError, GraphQL::StringEncodingError, GraphQL::IntegerEncodingError
1318-
raise type_error
1328+
ctx.errors << execution_error
1329+
when GraphQL::LegacyScalarCoercionError
1330+
if spec_compliant_scalar_coercion_errors == true
1331+
execution_error = GraphQL::CoercionError.new(type_error.message)
1332+
raise execution_error
1333+
else
1334+
warn <<~MSG
1335+
Scalar coercion errors will return GraphQL execution errors instead of raising Ruby exceptions in a future version.
1336+
To opt into this new behavior, set `Schema.spec_compliant_scalar_coercion_errors = true`.
1337+
To keep or customize the current behavior, add custom error handling in `Schema.type_error`.
1338+
MSG
1339+
1340+
raise type_error
1341+
end
13191342
when GraphQL::IntegerDecodingError
13201343
nil
1344+
when GraphQL::UnresolvedTypeError
1345+
raise type_error
13211346
end
13221347
end
13231348

lib/graphql/string_encoding_error.rb

Lines changed: 0 additions & 20 deletions
This file was deleted.

lib/graphql/types/float.rb

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,23 @@ module Types
55
class Float < GraphQL::Schema::Scalar
66
description "Represents signed double-precision fractional values as specified by [IEEE 754](https://en.wikipedia.org/wiki/IEEE_floating_point)."
77

8-
def self.coerce_input(value, _ctx)
8+
def self.coerce_input(value, ctx)
99
value.is_a?(Numeric) ? value.to_f : nil
1010
end
1111

12-
def self.coerce_result(value, _ctx)
13-
value.to_f
12+
def self.coerce_result(value, ctx)
13+
coerced_value = Float(value, exception: false)
14+
15+
if coerced_value.nil? || !coerced_value.finite?
16+
error = GraphQL::LegacyScalarCoercionError.new(
17+
"Float cannot represent non numeric value: #{value.inspect}",
18+
value: value,
19+
context: ctx
20+
)
21+
ctx.schema.type_error(error, ctx)
22+
else
23+
coerced_value
24+
end
1425
end
1526

1627
default_scalar true

lib/graphql/types/int.rb

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,16 @@ def self.coerce_input(value, ctx)
2121
end
2222

2323
def self.coerce_result(value, ctx)
24-
value = value.to_i
25-
if value >= MIN && value <= MAX
24+
value = Integer(value, exception: false)
25+
26+
if value && (value >= MIN && value <= MAX)
2627
value
2728
else
28-
err = GraphQL::IntegerEncodingError.new(value, context: ctx)
29+
err = GraphQL::LegacyScalarCoercionError.new(
30+
"Int cannot represent non 32-bit signed integer value: #{value.inspect}",
31+
value: value,
32+
context: ctx
33+
)
2934
ctx.schema.type_error(err, ctx)
3035
end
3136
end

lib/graphql/types/string.rb

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,12 @@ def self.coerce_result(value, ctx)
1515
str.encode!(Encoding::UTF_8)
1616
end
1717
rescue EncodingError
18-
err = GraphQL::StringEncodingError.new(str, context: ctx)
19-
ctx.schema.type_error(err, ctx)
18+
error = GraphQL::LegacyScalarCoercionError.new(
19+
"String cannot represent value: #{value.inspect}",
20+
value: value,
21+
context: ctx
22+
)
23+
ctx.schema.type_error(error, ctx)
2024
end
2125

2226
def self.coerce_input(value, _ctx)

spec/graphql/types/float_spec.rb

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,88 @@
1616
assert_nil GraphQL::Types::Float.coerce_isolated_input(enum)
1717
end
1818
end
19+
20+
describe "coerce_result" do
21+
it "coercess ints and floats" do
22+
err_ctx = GraphQL::Query.new(Dummy::Schema, "{ __typename }").context
23+
24+
assert_equal 1.0, GraphQL::Types::Float.coerce_result(1, err_ctx)
25+
assert_equal 1.0, GraphQL::Types::Float.coerce_result("1", err_ctx)
26+
assert_equal 1.0, GraphQL::Types::Float.coerce_result("1.0", err_ctx)
27+
assert_equal 6.1, GraphQL::Types::Float.coerce_result(6.1, err_ctx)
28+
end
29+
30+
it "rejects other types" do
31+
err_ctx = GraphQL::Query.new(Dummy::Schema, "{ __typename }").context
32+
33+
assert_raises(GraphQL::LegacyScalarCoercionError) do
34+
GraphQL::Types::Float.coerce_result("foo", err_ctx)
35+
end
36+
37+
assert_raises(GraphQL::LegacyScalarCoercionError) do
38+
GraphQL::Types::Float.coerce_result(1.0 / 0, err_ctx)
39+
end
40+
end
41+
42+
describe "with Schema.spec_compliant_scalar_coercion_errors" do
43+
class FloatScalarSchema < GraphQL::Schema
44+
class Query < GraphQL::Schema::Object
45+
field :float, GraphQL::Types::Float, null: true do
46+
argument :value, GraphQL::Types::Float, required: true
47+
end
48+
49+
field :bad_float, GraphQL::Types::Float, null: true
50+
51+
def float(value:)
52+
value
53+
end
54+
55+
def bad_float
56+
Float::INFINITY
57+
end
58+
end
59+
60+
query(Query)
61+
end
62+
63+
class FloatSpecCompliantErrors < FloatScalarSchema
64+
spec_compliant_scalar_coercion_errors true
65+
end
66+
67+
class FloatNonSpecComplaintErrors < FloatScalarSchema
68+
spec_compliant_scalar_coercion_errors false
69+
end
70+
71+
it "returns GraphQL execution errors with spec_compliant_scalar_coercion_errors enabled" do
72+
query = "{ badFloat }"
73+
result = FloatSpecCompliantErrors.execute(query)
74+
75+
assert_equal(
76+
{
77+
"errors" => [
78+
{
79+
"message" => "Float cannot represent non numeric value: Infinity",
80+
"locations" => [{ "line" => 1, "column" => 3 }],
81+
"path" => ["badFloat"],
82+
},
83+
],
84+
"data" => {
85+
"badFloat" => nil,
86+
}
87+
},
88+
result.to_h
89+
)
90+
end
91+
92+
it "raises Ruby exceptions with spec_compliant_scalar_coercion_errors disabled" do
93+
query = "{ badFloat }"
94+
95+
error = assert_raises(GraphQL::LegacyScalarCoercionError) do
96+
FloatNonSpecComplaintErrors.execute(query)
97+
end
98+
99+
assert_equal("Float cannot represent non numeric value: Infinity", error.message)
100+
end
101+
end
102+
end
19103
end

spec/graphql/types/int_spec.rb

Lines changed: 67 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22
require "spec_helper"
33

4+
45
describe GraphQL::Types::Int do
56
describe "coerce_input" do
67
it "accepts ints within the bounds" do
@@ -26,21 +27,77 @@
2627
assert_equal(-(2**31), GraphQL::Types::Int.coerce_result(-(2**31), context))
2728
end
2829

29-
it "replaces values, if configured to do so" do
30-
assert_equal Dummy::Schema::MAGIC_INT_COERCE_VALUE, GraphQL::Types::Int.coerce_result(99**99, context)
31-
end
32-
3330
it "raises on values out of bounds" do
3431
err_ctx = GraphQL::Query.new(Dummy::Schema, "{ __typename }").context
35-
assert_raises(GraphQL::IntegerEncodingError) { GraphQL::Types::Int.coerce_result(2**31, err_ctx) }
36-
err = assert_raises(GraphQL::IntegerEncodingError) { GraphQL::Types::Int.coerce_result(-(2**31 + 1), err_ctx) }
37-
assert_equal "Integer out of bounds: -2147483649. Consider using ID or GraphQL::Types::BigInt instead.", err.message
32+
assert_raises(GraphQL::LegacyScalarCoercionError) { GraphQL::Types::Int.coerce_result(2**31, err_ctx) }
33+
err = assert_raises(GraphQL::LegacyScalarCoercionError) { GraphQL::Types::Int.coerce_result(-(2**31 + 1), err_ctx) }
34+
assert_equal "Int cannot represent non 32-bit signed integer value: -2147483649", err.message
3835

39-
err = assert_raises GraphQL::IntegerEncodingError do
36+
err = assert_raises GraphQL::LegacyScalarCoercionError do
4037
Dummy::Schema.execute("{ hugeInteger }")
4138
end
42-
expected_err = "Integer out of bounds: 2147483648 @ hugeInteger (Query.hugeInteger). Consider using ID or GraphQL::Types::BigInt instead."
43-
assert_equal expected_err, err.message
39+
assert_equal "Int cannot represent non 32-bit signed integer value: 2147483648", err.message
40+
end
41+
42+
describe "with Schema.spec_compliant_scalar_coercion_errors" do
43+
class IntScalarSchema < GraphQL::Schema
44+
class Query < GraphQL::Schema::Object
45+
field :int, GraphQL::Types::Int, null: true do
46+
argument :value, GraphQL::Types::Int, required: true
47+
end
48+
49+
field :bad_int, GraphQL::Types::Int, null: true
50+
51+
def int(value:)
52+
value
53+
end
54+
55+
def bad_int
56+
2**31 # Out of range
57+
end
58+
end
59+
60+
query(Query)
61+
end
62+
63+
class IntSpecCompliantErrors < IntScalarSchema
64+
spec_compliant_scalar_coercion_errors true
65+
end
66+
67+
class IntNonSpecComplaintErrors < IntScalarSchema
68+
spec_compliant_scalar_coercion_errors false
69+
end
70+
71+
it "returns GraphQL execution errors with spec_compliant_scalar_coercion_errors enabled" do
72+
query = "{ badInt }"
73+
result = IntSpecCompliantErrors.execute(query)
74+
75+
assert_equal(
76+
{
77+
"errors" => [
78+
{
79+
"message" => "Int cannot represent non 32-bit signed integer value: 2147483648",
80+
"locations" => [{ "line" => 1, "column" => 3 }],
81+
"path" => ["badInt"],
82+
},
83+
],
84+
"data" => {
85+
"badInt" => nil,
86+
}
87+
},
88+
result.to_h
89+
)
90+
end
91+
92+
it "raises Ruby exceptions with spec_compliant_scalar_coercion_errors disabled" do
93+
query = "{ badInt }"
94+
95+
error = assert_raises(GraphQL::LegacyScalarCoercionError) do
96+
IntNonSpecComplaintErrors.execute(query)
97+
end
98+
99+
assert_equal("Int cannot represent non 32-bit signed integer value: 2147483648", error.message)
100+
end
44101
end
45102
end
46103
end

0 commit comments

Comments
 (0)