diff --git a/lib/graphql.rb b/lib/graphql.rb index 110a40a54d7..b3fcac42961 100644 --- a/lib/graphql.rb +++ b/lib/graphql.rb @@ -93,6 +93,8 @@ class << self autoload :AnalysisError, "graphql/analysis_error" autoload :CoercionError, "graphql/coercion_error" autoload :InvalidNameError, "graphql/invalid_name_error" + autoload :FloatDecodingError, "graphql/float_decoding_error" + autoload :FloatEncodingError, "graphql/float_encoding_error" autoload :IntegerDecodingError, "graphql/integer_decoding_error" autoload :IntegerEncodingError, "graphql/integer_encoding_error" autoload :StringEncodingError, "graphql/string_encoding_error" diff --git a/lib/graphql/float_decoding_error.rb b/lib/graphql/float_decoding_error.rb new file mode 100644 index 00000000000..8e0f9aed53b --- /dev/null +++ b/lib/graphql/float_decoding_error.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true +module GraphQL + # This error is raised when `Types::Float` is given a non-finite input value. + class FloatDecodingError < GraphQL::RuntimeTypeError + # The value which couldn't be decoded + attr_reader :float_value + + def initialize(value) + @float_value = value + super("Float is not finite: #{value.inspect}.") + end + end +end diff --git a/lib/graphql/float_encoding_error.rb b/lib/graphql/float_encoding_error.rb new file mode 100644 index 00000000000..f1c01cb3205 --- /dev/null +++ b/lib/graphql/float_encoding_error.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +module GraphQL + # This error is raised when `Types::Float` is asked to return a non-finite value. + class FloatEncodingError < GraphQL::RuntimeTypeError + # The value which couldn't be encoded + attr_reader :float_value + + # @return [GraphQL::Schema::Field] The field that returned a non-finite float + attr_reader :field + + # @return [Array] Where the field appeared in the GraphQL response + attr_reader :path + + def initialize(value, context:) + @float_value = value + @field = context[:current_field] + @path = context[:current_path] + message = "Float is not finite: #{value.inspect}".dup + if @path + message << " @ #{@path.join(".")}" + end + if @field + message << " (#{@field.path})" + end + super("#{message}.") + end + end +end diff --git a/lib/graphql/language.rb b/lib/graphql/language.rb index 5280f9c5d07..d85133cdbbe 100644 --- a/lib/graphql/language.rb +++ b/lib/graphql/language.rb @@ -34,8 +34,8 @@ def self.serialize(value) JSON.generate(value) end rescue JSON::GeneratorError - if Float::INFINITY == value - "Infinity" + if value.is_a?(Float) && !value.finite? + value.to_s else raise end diff --git a/lib/graphql/query/variable_validation_error.rb b/lib/graphql/query/variable_validation_error.rb index 3a934dfc164..41f70e116e5 100644 --- a/lib/graphql/query/variable_validation_error.rb +++ b/lib/graphql/query/variable_validation_error.rb @@ -22,7 +22,7 @@ def to_h # It is possible there are other extension items in this error, so handle # a one level deep merge explicitly. However beyond that only show the # latest value and problems. - super.merge({ "extensions" => { "value" => value, "problems" => validation_result.problems }}) do |key, oldValue, newValue| + super.merge({ "extensions" => { "value" => value_for_extensions, "problems" => validation_result.problems }}) do |key, oldValue, newValue| if oldValue.respond_to?(:merge) oldValue.merge(newValue) else @@ -33,6 +33,21 @@ def to_h private + def value_for_extensions(value = @value) + case value + when Array + value.map { |item| value_for_extensions(item) } + when Hash + value.each_with_object({}) do |(key, item), result| + result[key] = value_for_extensions(item) + end + when Float + value.finite? ? value : value.to_s + else + value + end + end + def problem_fields @problem_fields ||= @validation_result .problems diff --git a/lib/graphql/schema.rb b/lib/graphql/schema.rb index f6801ddbfb2..48f502b4475 100644 --- a/lib/graphql/schema.rb +++ b/lib/graphql/schema.rb @@ -1338,9 +1338,9 @@ def type_error(type_error, context) context.errors << execution_error execution_error - when GraphQL::UnresolvedTypeError, GraphQL::StringEncodingError, GraphQL::IntegerEncodingError + when GraphQL::UnresolvedTypeError, GraphQL::StringEncodingError, GraphQL::FloatEncodingError, GraphQL::IntegerEncodingError raise type_error - when GraphQL::IntegerDecodingError + when GraphQL::FloatDecodingError, GraphQL::IntegerDecodingError nil end end diff --git a/lib/graphql/types/float.rb b/lib/graphql/types/float.rb index 7551899ed5a..2016aefcbbb 100644 --- a/lib/graphql/types/float.rb +++ b/lib/graphql/types/float.rb @@ -5,12 +5,26 @@ module Types class Float < GraphQL::Schema::Scalar description "Represents signed double-precision fractional values as specified by [IEEE 754](https://en.wikipedia.org/wiki/IEEE_floating_point)." - def self.coerce_input(value, _ctx) - value.is_a?(Numeric) ? value.to_f : nil + def self.coerce_input(value, ctx) + return if !value.is_a?(Numeric) + + value = value.to_f + if value.finite? + value + else + err = GraphQL::FloatDecodingError.new(value) + ctx.schema.type_error(err, ctx) + end end - def self.coerce_result(value, _ctx) - value.to_f + def self.coerce_result(value, ctx) + value = value.to_f + if value.finite? + value + else + err = GraphQL::FloatEncodingError.new(value, context: ctx) + ctx.schema.type_error(err, ctx) + end end default_scalar true diff --git a/spec/graphql/query/variable_validation_error_spec.rb b/spec/graphql/query/variable_validation_error_spec.rb index eea4268db12..f73a826370a 100644 --- a/spec/graphql/query/variable_validation_error_spec.rb +++ b/spec/graphql/query/variable_validation_error_spec.rb @@ -47,5 +47,13 @@ def extensions } assert_equal error.to_h, as_hash end + + it 'makes non-finite values JSON-safe' do + error = subject.new(ast, type, { "values" => [Float::NAN, Float::INFINITY] }, validation_result) + value = error.to_h.dig("extensions", "value") + + assert_equal({ "values" => ["NaN", "Infinity"] }, value) + JSON.generate(error.to_h) + end end end diff --git a/spec/graphql/types/float_spec.rb b/spec/graphql/types/float_spec.rb index ba2b8028802..7077abd3ff8 100644 --- a/spec/graphql/types/float_spec.rb +++ b/spec/graphql/types/float_spec.rb @@ -3,6 +3,28 @@ describe GraphQL::Types::Float do let(:enum) { GraphQL::Language::Nodes::Enum.new(name: 'MILK') } + let(:schema) { + query_type = Class.new(GraphQL::Schema::Object) do + graphql_name "Query" + + field :echo, Float do + argument :value, Float + end + field :non_finite, Float, resolve_static: true + + def self.non_finite(_context) + ::Float::NAN + end + + def non_finite + self.class.non_finite(nil) + end + end + + Class.new(GraphQL::Schema) do + query(query_type) + end + } describe "coerce_input" do it "accepts ints and floats" do @@ -15,5 +37,49 @@ assert_nil GraphQL::Types::Float.coerce_isolated_input(true) assert_nil GraphQL::Types::Float.coerce_isolated_input(enum) end + + it "rejects non-finite values" do + assert_nil GraphQL::Types::Float.coerce_isolated_input(Float::NAN) + assert_nil GraphQL::Types::Float.coerce_isolated_input(Float::INFINITY) + assert_nil GraphQL::Types::Float.coerce_isolated_input(-Float::INFINITY) + end + + it "rejects non-finite literals and variables" do + literal_result = schema.execute("{ echo(value: 1e400) }") + assert_includes literal_result["errors"].first["message"], "has an invalid value" + + variable_result = schema.execute( + "query($value: Float!) { echo(value: $value) }", + variables: { "value" => Float::NAN }, + ) + assert_includes variable_result["errors"].first["message"], "provided invalid value" + assert_equal "NaN", variable_result["errors"].first.dig("extensions", "value") + [literal_result, variable_result].each { |result| JSON.generate(result.to_h) } + end + end + + describe "coerce_result" do + it "accepts finite values" do + assert_equal 1.0, GraphQL::Types::Float.coerce_isolated_result(1) + assert_equal 6.1, GraphQL::Types::Float.coerce_isolated_result(6.1) + end + + it "raises on non-finite values" do + assert_raises(GraphQL::FloatEncodingError) do + GraphQL::Types::Float.coerce_isolated_result(Float::INFINITY) + end + assert_raises(GraphQL::FloatEncodingError) do + GraphQL::Types::Float.coerce_isolated_result(-Float::INFINITY) + end + + err = assert_raises(GraphQL::FloatEncodingError) do + schema.execute("{ nonFinite }") + end + expected_message = exec_next_error_message( + "Query.nonFinite", + "Float is not finite: NaN#{if_exec_next("", " @ nonFinite (Query.nonFinite)")}.", + ) + assert_equal expected_message, err.message + end end end