-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Description
Dropping this in here as it could be very good for everyone if we all used the same input coercion process. The GraphQL Ruby one is pretty heavy at the moment and doesn't always match the spec. The code below was assembled in collaboration with Claude over many, many passes through GraphQL-js reference implementations and its tests. We've found that it matches the spec better than GraphQL Ruby, and benchmarks around twice as fast for processing large inputs. It has a few opinions:
-
All input coercion and GraphQL Ruby validation is performed during a single traversal pass. Coercion errors are raised immediately at the step where they occur, while validation errors are cached and passed forward to where the input is actually consumed, and raise there upon execution.
-
All input is evaluated statically. Arguments are assumed the same across all list objects, and validators always receive
nilas their object. Assuming all input can be evaluated statically saves a ton of work. -
All resolved arguments come out as frozen hashes. This allows read-only arguments to come out with frozen variable hashes inlined into their structure without adding an extra traversal of variables. We offer a utility to deep-copy frozen arguments that field resolver can use if desired. Most of the time though, the frozen read-only structure is cheap and sufficient.
Usage:
@input = InputFormatter.new(@context)
# seed the internal cache of variables...
@input.coerce_variable_values(@query.selected_operation.variables, @query.provided_variables)
# format arguments...
arguments, argument_errors = @input.coerce_argument_values(field_definition, field_nodes.first)class InputFormatter
#: type input_hash = Hash[String | Symbol, untyped]
#: type variables_hash = Hash[String, untyped]
GRAPHQL_PRINTER = GraphQL::Language::Printer.new
VALUE_CANNOT_BE_NULL = "Expected value to not be null".freeze
class InputError < ExecutionError
#: error_path
attr_reader :path
#: (
#| String message,
#| ?path: error_path,
#| ?nodes: Array[GraphQL::Language::Nodes::AbstractNode],
#| ?extensions: extensions_hash?
#| ) -> void
def initialize(message, path: EMPTY_ARRAY, nodes: EMPTY_ARRAY, extensions: nil)
@path = path
super(message, nodes:, extensions:)
end
end
class InputCoercionError < InputError; end
class InputValidatorError < InputError; end
class InputValidationErrorSet < ExecutionError
#: Array[ExecutionError]
attr_reader :errors
#: (
#| ?String?,
#| ?exec_field: GraphQL::BreadthExec::Executor::ExecutionField?,
#| ?errors: Array[ExecutionError]
#| ) -> void
def initialize(message = nil, exec_field: nil, errors: [])
@errors = errors
super(message || errors.map(&:message).join(", "), exec_field:)
end
#: (
#| ?String?,
#| ?extensions: extensions_hash?,
#| ?cause: Exception?
#| ) -> ExecutionErrorSet
def add_error(message = nil, extensions: nil, cause: nil)
@errors << ExecutionError.new(message, exec_field:, extensions:, cause:)
self
end
# @override
#: () -> Array[GraphQL::Language::Nodes::AbstractNode]
def nodes
@errors.flat_map(&:nodes)
end
# @override
#: () { (ExecutionError) -> void } -> void
def each(&block)
@errors.each(&block)
end
end
# Maintains a partial state cache for an individual input traversal
# this would allow many traversals to be parallelized against a shared input cache
class State
#: error_path
attr_reader :path
#: Array[InputCoercionError]
attr_reader :coercion_errors
#: Array[InputValidatorError]
attr_reader :validator_errors
#: GraphQL::Language::Nodes::AbstractNode?
attr_accessor :current_node
def initialize
@coercion_errors = EMPTY_ARRAY
@validator_errors = EMPTY_ARRAY
@path = EMPTY_ARRAY
end
#: (String | Integer) -> untyped
def add_path(segment)
@path = [] if @path.frozen?
@path << segment
return nil unless block_given?
begin
yield
ensure
@path.pop
end
end
#: (String | InputCoercionError, ?extensions: extensions_hash?) -> Util::NilLike
def add_coercion_error(err, extensions: nil)
err = InputCoercionError.new(err, nodes: current_node ? [current_node] : EMPTY_ARRAY, path: @path.dup, extensions:) if err.is_a?(String)
@coercion_errors = [] if @coercion_errors.frozen?
@coercion_errors << err
UNDEFINED
end
#: (String | InputValidatorError, ?extensions: extensions_hash?) -> Util::NilLike
def add_validator_error(err, extensions: nil)
err = InputValidatorError.new(err, nodes: current_node ? [current_node] : EMPTY_ARRAY, path: @path.dup, extensions:) if err.is_a?(String)
@validator_errors = [] if @validator_errors.frozen?
@validator_errors << err
UNDEFINED
end
#: () -> State
def reset!
self.current_node = nil
@coercion_errors.clear unless @coercion_errors.frozen?
@validator_errors.clear unless @validator_errors.frozen?
@path.clear unless @path.frozen?
self
end
end
#: variables_hash
attr_reader :variables
#: Hash[String, Array[InputValidatorError]]
attr_reader :variable_validator_errors
#: bool
attr_accessor :symbolize_keys
#: (GraphQL::Query::Context, ?symbolize_keys: bool) -> void
def initialize(context, symbolize_keys: true)
@context = context
@variables = EMPTY_OBJECT
@variable_validator_errors = EMPTY_OBJECT
@symbolize_keys = symbolize_keys
@default_state = State.new
end
#: -> bool
def symbolize_keys? = @symbolize_keys
# based on graphql.js typeFromAST
#: (untyped) -> singleton(GraphQL::Schema::Member)?
def type_from_ast(node)
case node
when GraphQL::Language::Nodes::ListType
type_from_ast(node.of_type)&.to_list_type
when GraphQL::Language::Nodes::NonNullType
type_from_ast(node.of_type)&.to_non_null_type
else
@context.types.type(node.name)
end
end
# based on graphql.js coerceVariableValues
#: (
#| Array[GraphQL::Language::Nodes::VariableDefinition] var_nodes,
#| input_hash inputs,
#| ?state: State,
#| ) -> variables_hash
def coerce_variable_values(var_nodes, inputs, state: @default_state.reset!)
return @variables if var_nodes.empty? && inputs.empty?
variable_errors = []
@variables = {}
var_nodes.each do |var_node|
# top-level variable name always remains keyed in unmodified string form...
var_name = var_node.name
var_type = type_from_ast(var_node.type)
value = fetch_with_indifferent_access(inputs, var_name)
# Variable defines a bogus input type? (ie: Object, Interface, or Union)
if var_type.nil? || !var_type.unwrap.kind.input?
type_node = var_node.type #: untyped
type_node = type_node.of_type while type_node.is_a?(GraphQL::Language::Nodes::WrapperType)
# This should only ever manifest during static validations,
# which precedes/short-circuits the variable validation pass.
# This outcome is only included as a redundancy safeguard.
variable_errors << InputCoercionError.new(
"#{type_node.name} isn't a valid input type (on $#{var_name})",
nodes: [var_node],
)
elsif value.equal?(UNDEFINED)
if !var_node.default_value.nil?
# Calling `value_from_ast` should never error here assuming the document is properly validated
@variables[var_name] = value_from_ast(var_node.default_value, var_type, state:)
elsif var_type.non_null?
variable_errors << InputCoercionError.new(
"Variable \"$#{var_name}\" of required type \"#{var_type.to_type_signature}\" was not provided.",
nodes: [var_node],
extensions: {
"value" => nil,
"problems" => [{ "path" => [], "explanation" => VALUE_CANNOT_BE_NULL }],
},
)
end
elsif value.nil? && var_type.non_null?
variable_errors << InputCoercionError.new(
"Variable \"$#{var_name}\" of non-null type \"#{var_type.to_type_signature}\" must not be null.",
nodes: [var_node],
extensions: {
"value" => nil,
"problems" => [{ "path" => [], "explanation" => VALUE_CANNOT_BE_NULL }],
},
)
else
# Coerce the provided value into the named variable field, and collect any value coercion errors.
state.current_node = var_node
@variables[var_name] = coerce_input_value(value, var_type, state:)
end
rescue InputCoercionError => e
state.add_coercion_error(e)
ensure
unless state.coercion_errors.empty?
message = "Variable $#{var_name} of type #{var_type&.to_type_signature} was provided invalid value"
unless state.coercion_errors.first.path.empty?
error_descs = state.coercion_errors.map { "#{_1.path.join(".")} (#{_1.message.sub(/\.$/, "")})" }
message << " for #{error_descs.join(", ")}"
end
extensions = state.coercion_errors.each_with_object({}) { |obj, ext| ext.merge!(obj.extensions) if obj.extensions }
extensions["value"] = value
extensions["problems"] = state.coercion_errors.map { { "path" => _1.path, "explanation" => _1.message } }
variable_errors << InputCoercionError.new(
message,
nodes: [var_node],
extensions:,
)
end
unless state.validator_errors.empty?
# variable validator errors get stored for later to be incorporated while coercing arguments
@variable_validator_errors = {} if @variable_validator_errors.frozen?
@variable_validator_errors[var_name] = state.validator_errors.dup
end
state.reset!
end
raise InputValidationErrorSet.new(errors: variable_errors) unless variable_errors.empty?
@variables.freeze
end
# based on graphql.js getVariableValues
# The "variables" argument assumes the provided variables have already been coerced.
#: (
#| GraphQL::Schema::Field | singleton(GraphQL::Schema::Directive),
#| (GraphQL::Language::Nodes::Field | GraphQL::Language::Nodes::Directive)?,
#| ?variables: variables_hash,
#| ?state: State,
#| ) -> [graphql_arguments, Array[InputError]]
def coerce_argument_values(member, node, variables: @variables, state: @default_state.reset!)
arg_defs = @context.types.arguments(member)
arg_nodes = node&.arguments || EMPTY_ARRAY
return [EMPTY_OBJECT, EMPTY_ARRAY] if arg_defs.empty?
arguments = {}
argument_errors = []
arg_nodes_by_name = arg_nodes.each_with_object({}) do |arg_node, acc|
acc[arg_node.name] = arg_node
end
arg_defs.each do |arg|
arg_node = arg_nodes_by_name[arg.graphql_name]
arg_key = symbolize_keys? ? arg.keyword : arg.graphql_name
state.add_path(arg.graphql_name)
state.current_node = arg_node
if arg_node.nil?
if arg.default_value?
arg_value = format_default_value(arg.default_value, arg.type, state:)
arguments[arg_key] = validate_value(arg, arg_value, state:)
elsif arg.type.non_null?
argument_errors << InputCoercionError.new(
"Argument \"#{arg.graphql_name}\" of required type \"#{arg.type.to_type_signature}\" was not provided.",
nodes: node ? [node] : EMPTY_ARRAY,
)
end
next
end
value_node = arg_node.value
is_null = false
if value_node.is_a?(GraphQL::Language::Nodes::NullValue)
is_null = true
elsif value_node.is_a?(GraphQL::Language::Nodes::VariableIdentifier)
variable_value = (variables || EMPTY_OBJECT).fetch(value_node.name, UNDEFINED)
if variable_value.equal?(UNDEFINED)
if arg.default_value?
state.current_node = value_node
arg_value = format_default_value(arg.default_value, arg.type, state:)
arguments[arg_key] = validate_value(arg, arg_value, state:)
elsif arg.type.non_null?
argument_errors << InputCoercionError.new(
"Argument \"#{arg.graphql_name}\" of required type \"#{arg.type.to_type_signature}\" " \
"was provided the variable \"$#{value_node.name}\" which was not provided a runtime value.",
nodes: [value_node],
)
end
next
end
is_null = variable_value.nil?
end
if is_null && arg.type.non_null?
argument_errors << InputCoercionError.new(
"Argument \"#{arg.graphql_name}\" of non-null type \"#{arg.type.to_type_signature}\" must not be null.",
nodes: [value_node],
)
next
end
arg_value = value_from_ast(value_node, arg.type, variables:, state:)
if arg_value.equal?(UNDEFINED)
argument_errors << InputCoercionError.new(
"Argument \"#{arg.graphql_name}\" has invalid value #{print_node(value_node)}",
nodes: [value_node],
)
next
end
arguments[arg_key] = validate_value(arg, arg_value, state:)
argument_errors.concat(state.validator_errors) unless state.validator_errors.empty?
ensure
state.reset!
end
if !argument_errors.empty? && argument_errors.any?(InputCoercionError)
# If we got here, then GraphQL Ruby passed something that BreadthExec didn't (implies a BreadthExec bug)
# Report the error rather than raising so that shimmed fields can still use GraphQL Ruby values
GraphQL::BreadthExec.report_error(InputValidationErrorSet.new(errors: argument_errors.grep(InputCoercionError)))
arguments = EMPTY_OBJECT
else
# otherwise, validate the resolved field argument structure
validate_value(member, arguments, state:)
validate_value(member.resolver, arguments, state:, as: member) if member.is_a?(GraphQL::Schema::Field) && member.resolver
argument_errors.concat(state.validator_errors) unless state.validator_errors.empty?
end
[arguments.freeze, argument_errors]
end
# based on graphql.js coerceInputValue
#: (
#| untyped input_value,
#| untyped type,
#| ?state: State,
#| ) -> untyped
def coerce_input_value(input_value, type, state: State.new)
if type.non_null?
if !input_value.nil?
coerce_input_value(input_value, type.of_type, state:)
else
# this check is already covered by the main coerce_variable_values loop
state.add_coercion_error("Non-null type cannot be null.")
end
elsif input_value.nil?
nil
elsif type.list?
item_type = type.of_type
if input_value.is_a?(Array)
input_value.map.with_index do |item, index|
state.add_path(index) { coerce_input_value(item, item_type, state:) }
end.freeze
else
# Lists accept a non-list value as a set of one.
item_value = state.add_path(0) { coerce_input_value(input_value, item_type, state:) }
[item_value].freeze
end
elsif type.kind.input_object?
unless input_value.is_a?(Hash)
return state.add_coercion_error("Expected type \"#{type.graphql_name}\" to be an object.")
end
coerced_obj = {}
arg_defs_by_name = arguments_map_for_type(type)
arg_defs_by_name.each_value do |arg|
state.add_path(arg.graphql_name)
arg_key = symbolize_keys? ? arg.keyword : arg.graphql_name
arg_value = fetch_with_indifferent_access(input_value, arg.graphql_name)
if arg_value.equal?(UNDEFINED)
if arg.default_value?
arg_value = format_default_value(arg.default_value, arg.type, state:)
coerced_obj[arg_key] = validate_value(arg, arg_value, state:)
elsif arg.type.non_null?
state.add_coercion_error("Field \"#{arg.graphql_name}\" of required type \"#{arg.type.to_type_signature}\" was not provided.")
end
next
end
arg_value = coerce_input_value(arg_value, arg.type, state:)
coerced_obj[arg_key] = validate_value(arg, arg_value, state:)
ensure
state.path.pop
end
if input_value.size > coerced_obj.size
input_value.each_key do |field_name|
unless arg_defs_by_name.key?(field_name.to_s)
state.add_coercion_error("Field \"#{field_name}\" is not defined by type \"#{type.graphql_name}\".")
end
end
end
if one_of_input_object?(type)
if coerced_obj.size != 1
state.add_coercion_error("Exactly one key must be specified for OneOf type \"#{type.graphql_name}\".")
end
coerced_obj.each do |arg_key, value|
next unless value.nil?
state.add_coercion_error("Exactly one value must be specified for OneOf type \"#{type.graphql_name}\", but \"#{arg_key}\" was null.")
end
end
validate_value(type, coerced_obj.freeze, state:)
elsif type.kind.leaf?
result = begin
type.coerce_input(input_value, @context)
rescue GraphQL::ExecutionError => e
return state.add_coercion_error(e.message, extensions: e.extensions)
rescue StandardError => e
return state.add_coercion_error("Expected type \"#{type.graphql_name}\".")
end
if result.nil?
if type.kind.enum?
# variable validation (see test_errors_for_incorrect_variable_enums)
state.add_coercion_error("Value does not exist in '#{type.graphql_name}' enum.")
elsif type.default_scalar?
# variable validation (see test_errors_for_incorrect_variable_values)
state.add_coercion_error("Cannot represent non-#{type.graphql_name} value.")
end
end
result
else
raise InputCoercionError, "Unexpected input type: #{type.graphql_name}."
end
end
# based on graphql.js valueFromAST
# This method should never raise errors in a validated document,
# because we know that everything is correct by the AST.
#: (
#| GraphQL::Language::Nodes::AbstractNode? | Array[GraphQL::Language::Nodes::AbstractNode] value_node,
#| untyped type,
#| ?variables: variables_hash?,
#| ?state: State,
#| ) -> untyped
def value_from_ast(value_node, type, variables: nil, state: State.new)
if value_node.nil?
state.add_coercion_error("Expected value node to be non-null.")
elsif value_node.is_a?(GraphQL::Language::Nodes::VariableIdentifier)
var_name = value_node.name
variable_value = (variables || EMPTY_OBJECT).fetch(var_name, UNDEFINED)
if variable_value.equal?(UNDEFINED)
return state.add_coercion_error("Expected variable $#{var_name} to be defined.")
end
if variable_value.nil? && type.non_null?
return state.add_coercion_error("Expected variable $#{var_name} to be non-null.")
end
# Add all runtime errors that were cached for this variable field
@variable_validator_errors.fetch(var_name, EMPTY_ARRAY).each do |e|
state.add_validator_error(InputValidatorError.new(e.message, nodes: e.nodes.dup, path: state.path + e.path))
end
variable_value
elsif type.non_null?
if value_node.is_a?(GraphQL::Language::Nodes::NullValue)
return state.add_coercion_error("Expected value node to be non-null.")
end
value_from_ast(value_node, type.of_type, variables:, state:)
elsif value_node.is_a?(GraphQL::Language::Nodes::NullValue)
nil
elsif type.list?
item_type = type.of_type
if value_node.is_a?(Array)
coerced_items = []
value_node.each_with_index do |item_node, index|
item_value = state.add_path(index) do
if missing_variable?(item_node, variables)
return state.add_coercion_error("Expected item to be non-null.") if item_type.non_null?
nil
else
value_from_ast(item_node, item_type, variables:, state:)
end
end
return UNDEFINED if item_value.equal?(UNDEFINED)
coerced_items << item_value
end
coerced_items.freeze
else
# Lists accept a non-list value as a set of one.
item_value = state.add_path(0) { value_from_ast(value_node, item_type, variables:, state:) }
return UNDEFINED if item_value.equal?(UNDEFINED)
[item_value].freeze
end
elsif type.kind.input_object?
unless value_node.is_a?(GraphQL::Language::Nodes::InputObject)
return state.add_coercion_error("Expected value node to be an input object.")
end
coerced_obj = {}
arg_nodes_by_name = value_node.arguments.each_with_object({}) do |arg_node, acc|
acc[arg_node.name] = arg_node
end
@context.types.arguments(type).each do |arg|
arg_node = arg_nodes_by_name[arg.graphql_name]
arg_key = symbolize_keys? ? arg.keyword : arg.graphql_name
state.add_path(arg.graphql_name)
state.current_node = arg_node
if arg_node.nil? || missing_variable?(arg_node.value, variables)
if arg.default_value?
arg_value = format_default_value(arg.default_value, arg.type, state:)
coerced_obj[arg_key] = validate_value(arg, arg_value, state:)
elsif arg.type.non_null?
return state.add_coercion_error("Expected argument \"#{arg.graphql_name}\" to be non-null.")
end
next
end
arg_value = value_from_ast(arg_node.value, arg.type, variables:, state:)
return UNDEFINED if arg_value.equal?(UNDEFINED)
coerced_obj[arg_key] = validate_value(arg, arg_value, state:)
ensure
state.path.pop
end
if one_of_input_object?(type)
if coerced_obj.size != 1
return state.add_coercion_error("Exactly one key must be specified for OneOf type \"#{type.graphql_name}\".")
end
coerced_obj.each do |arg_key, value|
if value.nil?
return state.add_coercion_error("Exactly one value must be specified for OneOf type \"#{type.graphql_name}\", but \"#{arg_key}\" was null.")
end
end
end
validate_value(type, coerced_obj.freeze, state:)
elsif type.kind.leaf?
if type.kind.enum?
if value_node.is_a?(GraphQL::Language::Nodes::Enum)
value_node = value_node.name
else
return state.add_coercion_error("Expected value node to be an enum.")
end
end
result = begin
type.coerce_input(value_node, @context)
rescue GraphQL::ExecutionError => e
return state.add_coercion_error(e.message, extensions: e.extensions)
rescue StandardError => e
return state.add_coercion_error("Expected type \"#{type.graphql_name}\".")
end
# default scalar values (String, Int, Boolean, etc.) cannot be nil
if result.nil? && type.kind.scalar? && type.default_scalar?
return state.add_coercion_error("Cannot represent non-#{type.graphql_name} value.")
end
result
else
raise InputCoercionError, "Unexpected input type: #{type.graphql_name}."
end
end
#: (
#| untyped default_value,
#| untyped type,
#| ?state: State,
#| ) -> untyped
def format_default_value(default_value, type, state: State.new)
return default_value if default_value.nil?
# Unwrap non-null wrapper since default values don't need null checking
type = type.of_type while type.non_null?
if type.list?
if default_value.is_a?(Array)
default_value.map.with_index do |item, index|
state.add_path(index) { format_default_value(item, type.of_type, state:) }
end.freeze
else
# Lists accept a non-list value as a set of one.
item_value = state.add_path(0) { format_default_value(default_value, type.of_type, state:) }
[item_value].freeze
end
elsif type.kind.input_object?
unless default_value.is_a?(Hash)
# should never happen in a valid document
return state.add_coercion_error("Expected default value for type \"#{type.graphql_name}\" to be an object.")
end
coerced_obj = {}
arg_defs_by_name = arguments_map_for_type(type)
default_value.each do |key, value|
arg = arg_defs_by_name[key.to_s]
if arg.nil?
# should never happen in a valid document
state.add_coercion_error("Invalid default field \"#{key}\" for type \"#{type.graphql_name}\".")
next
end
state.add_path(arg.graphql_name) do
arg_key = symbolize_keys? ? arg.keyword : arg.graphql_name
arg_value = format_default_value(value, arg.type, state:)
coerced_obj[arg_key] = validate_value(arg, arg_value, state:)
end
end
# don't validate this – it's not a complete value
coerced_obj.freeze
else
default_value
end
end
#: (untyped, untyped, ?state: State, ?as: untyped) -> untyped
def validate_value(member, value, state: State.new, as: nil)
unless member.validators.empty?
member.validators.each do |validator|
# Always validate statically with no object.
# BreadthExec does not support object-contextual arguments.
result = validator.validate(nil, @context, value)
next if result.nil? || result.empty?
interpolation_vars = { validated: (as || member).graphql_name, value: value.inspect }
case result
when String
state.add_validator_error(result % interpolation_vars)
when Array
result.each { |err| state.add_validator_error(err % interpolation_vars) }
else
raise ImplementationError, "Unexpected argument validation result: #{result.class}."
end
end
end
value
end
private
#: (input_hash, String, ?default: untyped) -> untyped
def fetch_with_indifferent_access(input, key, default: UNDEFINED)
input.fetch(key) { input.fetch(key.to_sym, default) }
end
#: (GraphQL::Language::Nodes::AbstractNode) -> String
def print_node(node)
GRAPHQL_PRINTER.print(node)
end
#: (
#| GraphQL::Language::Nodes::AbstractNode? | Array[GraphQL::Language::Nodes::AbstractNode] value_node,
#| variables_hash? variables,
#| ) -> bool
def missing_variable?(value_node, variables)
if value_node.is_a?(GraphQL::Language::Nodes::VariableIdentifier)
variables.nil? || !variables.key?(value_node.name)
else
false
end
end
#: (singleton(GraphQL::Schema::InputObject)) -> bool
def one_of_input_object?(type)
@one_of_types ||= {}.compare_by_identity
@one_of_types.fetch(type) do
@one_of_types[type] = !type.directives.empty? && type.directives.any? { _1.graphql_name == "oneOf" }
end
end
#: (GraphQL::Schema::Member type) -> Hash[String, GraphQL::Schema::Argument]
def arguments_map_for_type(type)
@arguments_map_by_type ||= {}.compare_by_identity
@arguments_map_by_type[type] ||= @context.types.arguments(type).each_with_object({}) do |arg, memo|
memo[arg.graphql_name] = arg
end
end
end