# frozen_string_literal: true
module GraphQL
class Schema
# This module provides a function for validating GraphQL types.
#
# Its {RULES} contain objects that respond to `#call(type)`. Rules are
# looked up for given types (by class ancestry), then applied to
# the object until an error is returned.
class Validation
# Lookup the rules for `object` based on its class,
# Then returns an error message or `nil`
# @param object [Object] something to be validated
# @return [String, Nil] error message, if there was one
def self.validate(object)
rules = RULES.reduce([]) do |memo, (parent_class, validations)|
memo + (object.is_a?(parent_class) ? validations : [])
end
# Stops after the first error
rules.reduce(nil) { |memo, rule| memo || rule.call(object) }
end
module Rules
# @param property_name [Symbol] The method to validate
# @param allowed_classes [Class] Classes which the return value may be an instance of
# @return [Proc] A proc which will validate the input by calling `property_name` and asserting it is an instance of one of `allowed_classes`
def self.assert_property(property_name, *allowed_classes)
allowed_classes_message = allowed_classes.map(&:name).join(" or ")
->(obj) {
property_value = obj.public_send(property_name)
is_valid_value = allowed_classes.any? { |allowed_class| property_value.is_a?(allowed_class) }
is_valid_value ? nil : "#{property_name} must return #{allowed_classes_message}, not #{property_value.class.name} (#{property_value.inspect})"
}
end
# @param property_name [Symbol] The method whose return value will be validated
# @param from_class [Class] The class for keys in the return value
# @param to_class [Class] The class for values in the return value
# @return [Proc] A proc to validate that validates the input by calling `property_name` and asserting that the return value is a Hash of `{from_class => to_class}` pairs
def self.assert_property_mapping(property_name, from_class, to_class)
->(obj) {
property_value = obj.public_send(property_name)
if !property_value.is_a?(Hash)
"#{property_name} must be a hash of {#{from_class.name} => #{to_class.name}}, not a #{property_value.class.name} (#{property_value.inspect})"
else
invalid_key, invalid_value = property_value.find { |key, value| !key.is_a?(from_class) || !value.is_a?(to_class) }
if invalid_key
"#{property_name} must map #{from_class} => #{to_class}, not #{invalid_key.class.name} => #{invalid_value.class.name} (#{invalid_key.inspect} => #{invalid_value.inspect})"
else
nil # OK
end
end
}
end
# @param property_name [Symbol] The method whose return value will be validated
# @param list_member_class [Class] The class which each member of the returned array should be an instance of
# @return [Proc] A proc to validate the input by calling `property_name` and asserting that the return is an Array of `list_member_class` instances
def self.assert_property_list_of(property_name, list_member_class)
->(obj) {
property_value = obj.public_send(property_name)
if !property_value.is_a?(Array)
"#{property_name} must be an Array of #{list_member_class.name}, not a #{property_value.class.name} (#{property_value.inspect})"
else
invalid_member = property_value.find { |value| !value.is_a?(list_member_class) }
if invalid_member
"#{property_name} must contain #{list_member_class.name}, not #{invalid_member.class.name} (#{invalid_member.inspect})"
else
nil # OK
end
end
}
end
def self.assert_named_items_are_valid(item_name, get_items_proc)
->(type) {
items = get_items_proc.call(type)
error_message = nil
items.each do |item|
item_message = GraphQL::Schema::Validation.validate(item)
if item_message
error_message = "#{item_name} #{item.name.inspect} #{item_message}"
break
end
end
error_message
}
end
FIELDS_ARE_VALID = Rules.assert_named_items_are_valid("field", ->(type) { type.all_fields })
HAS_ONE_OR_MORE_POSSIBLE_TYPES = ->(type) {
type.possible_types.length >= 1 ? nil : "must have at least one possible type"
}
NAME_IS_STRING = Rules.assert_property(:name, String)
DESCRIPTION_IS_STRING_OR_NIL = Rules.assert_property(:description, String, NilClass)
ARGUMENTS_ARE_STRING_TO_ARGUMENT = Rules.assert_property_mapping(:arguments, String, GraphQL::Argument)
ARGUMENTS_ARE_VALID = Rules.assert_named_items_are_valid("argument", ->(type) { type.arguments.values })
DEFAULT_VALUE_IS_VALID_FOR_TYPE = ->(type) {
if !type.default_value.nil? && type.type.is_a?(NonNullType)
return %Q(Variable #{type.name} of type "#{type.type}" is required and will not use the default value. Perhaps you meant to use type "#{type.type.of_type}".)
end
if !type.default_value.nil?
coerced_value = begin
type.type.coerce_isolated_result(type.default_value)
rescue => ex
ex
end
if coerced_value.nil? || coerced_value.is_a?(StandardError)
msg = "default value #{type.default_value.inspect} is not valid for type #{type.type}"
msg += " (#{coerced_value})" if coerced_value.is_a?(StandardError)
msg
end
end
}
TYPE_IS_VALID_INPUT_TYPE = ->(type) {
outer_type = type.type
inner_type = outer_type.is_a?(GraphQL::BaseType) ? outer_type.unwrap : nil
case inner_type
when GraphQL::ScalarType, GraphQL::InputObjectType, GraphQL::EnumType
# OK
else
"type must be a valid input type (Scalar or InputObject), not #{outer_type.class} (#{outer_type})"
end
}
SCHEMA_CAN_RESOLVE_TYPES = ->(schema) {
if schema.types.values.any? { |type| type.kind.resolves? } && schema.resolve_type_proc.nil?
"schema contains Interfaces or Unions, so you must define a `resolve_type -> (obj, ctx) { ... }` function"
else
# :+1:
end
}
SCHEMA_CAN_FETCH_IDS = ->(schema) {
has_node_field = schema.query && schema.query.all_fields.any?(&:relay_node_field)
if has_node_field && schema.object_from_id_proc.nil?
"schema contains `node(id:...)` field, so you must define a `object_from_id -> (id, ctx) { ... }` function"
else
# :rocket:
end
}
SCHEMA_CAN_GENERATE_IDS = ->(schema) {
has_id_field = schema.types.values.any? { |t| t.kind.fields? && t.all_fields.any? { |f| f.resolve_proc.is_a?(GraphQL::Relay::GlobalIdResolve) } }
if has_id_field && schema.id_from_object_proc.nil?
"schema contains `global_id_field`, so you must define a `id_from_object -> (obj, type, ctx) { ... }` function"
else
# :ok_hand:
end
}
SCHEMA_INSTRUMENTERS_ARE_VALID = ->(schema) {
errs = []
schema.instrumenters[:query].each do |inst|
if !inst.respond_to?(:before_query) || !inst.respond_to?(:after_query)
errs << "`instrument(:query, #{inst})` is invalid: must respond to `before_query(query)` and `after_query(query)` "
end
end
schema.instrumenters[:field].each do |inst|
if !inst.respond_to?(:instrument)
errs << "`instrument(:field, #{inst})` is invalid: must respond to `instrument(type, field)`"
end
end
if errs.any?
errs.join("Invalid instrumenters:\n" + errs.join("\n"))
else
nil
end
}
RESERVED_TYPE_NAME = ->(type) {
if type.name.start_with?('__') && !type.introspection?
# TODO: make this a hard failure in a later version
warn("Name #{type.name.inspect} must not begin with \"__\", which is reserved by GraphQL introspection.")
nil
else
# ok name
end
}
RESERVED_NAME = ->(named_thing) {
if named_thing.name.start_with?('__')
# TODO: make this a hard failure in a later version
warn("Name #{named_thing.name.inspect} must not begin with \"__\", which is reserved by GraphQL introspection.")
nil
else
# no worries
end
}
INTERFACES_ARE_IMPLEMENTED = ->(obj_type) {
field_errors = []
obj_type.interfaces.each do |interface_type|
interface_type.fields.each do |field_name, field_defn|
object_field = obj_type.get_field(field_name)
if object_field.nil?
field_errors << %|"#{field_name}" is required by #{interface_type.name} but not implemented by #{obj_type.name}|
elsif !GraphQL::Execution::Typecast.subtype?(field_defn.type, object_field.type)
field_errors << %|"#{field_name}" is required by #{interface_type.name} to return #{field_defn.type} but #{obj_type.name}.#{field_name} returns #{object_field.type}|
else
field_defn.arguments.each do |arg_name, arg_defn|
object_field_arg = object_field.arguments[arg_name]
if object_field_arg.nil?
field_errors << %|"#{arg_name}" argument is required by #{interface_type.name}.#{field_name} but not accepted by #{obj_type.name}.#{field_name}|
elsif arg_defn.type != object_field_arg.type
field_errors << %|"#{arg_name}" is required by #{interface_type.name}.#{field_defn.name} to accept #{arg_defn.type} but #{obj_type.name}.#{field_name} accepts #{object_field_arg.type} for "#{arg_name}"|
end
end
object_field.arguments.each do |arg_name, arg_defn|
if field_defn.arguments[arg_name].nil? && arg_defn.type.is_a?(GraphQL::NonNullType)
field_errors << %|"#{arg_name}" is not accepted by #{interface_type.name}.#{field_name} but required by #{obj_type.name}.#{field_name}|
end
end
end
end
end
if field_errors.any?
"#{obj_type.name} failed to implement some interfaces: #{field_errors.join(", ")}"
else
nil
end
}
end
# A mapping of `{Class => [Proc, Proc...]}` pairs.
# To validate an instance, find entries where `object.is_a?(key)` is true.
# Then apply each rule from the matching values.
RULES = {
GraphQL::Field => [
Rules::NAME_IS_STRING,
Rules::RESERVED_NAME,
Rules::DESCRIPTION_IS_STRING_OR_NIL,
Rules.assert_property(:deprecation_reason, String, NilClass),
Rules.assert_property(:type, GraphQL::BaseType),
Rules.assert_property(:property, Symbol, NilClass),
Rules::ARGUMENTS_ARE_STRING_TO_ARGUMENT,
Rules::ARGUMENTS_ARE_VALID,
],
GraphQL::Argument => [
Rules::NAME_IS_STRING,
Rules::RESERVED_NAME,
Rules::DESCRIPTION_IS_STRING_OR_NIL,
Rules::TYPE_IS_VALID_INPUT_TYPE,
Rules::DEFAULT_VALUE_IS_VALID_FOR_TYPE,
],
GraphQL::BaseType => [
Rules::NAME_IS_STRING,
Rules::RESERVED_TYPE_NAME,
Rules::DESCRIPTION_IS_STRING_OR_NIL,
],
GraphQL::ObjectType => [
Rules.assert_property_list_of(:interfaces, GraphQL::InterfaceType),
Rules::FIELDS_ARE_VALID,
Rules::INTERFACES_ARE_IMPLEMENTED,
],
GraphQL::InputObjectType => [
Rules::ARGUMENTS_ARE_STRING_TO_ARGUMENT,
Rules::ARGUMENTS_ARE_VALID,
],
GraphQL::UnionType => [
Rules.assert_property_list_of(:possible_types, GraphQL::ObjectType),
Rules::HAS_ONE_OR_MORE_POSSIBLE_TYPES,
],
GraphQL::InterfaceType => [
Rules::FIELDS_ARE_VALID,
],
GraphQL::Schema => [
Rules::SCHEMA_CAN_RESOLVE_TYPES,
Rules::SCHEMA_CAN_FETCH_IDS,
Rules::SCHEMA_CAN_GENERATE_IDS,
Rules::SCHEMA_INSTRUMENTERS_ARE_VALID,
],
}
end
end
end