# 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) # Hide LateBoundType from user-facing errors allowed_classes_message = allowed_classes.map(&:name).reject {|n| n.include?("LateBoundType") }.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, GraphQL::Schema::LateBoundType), 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_INSTRUMENTERS_ARE_VALID, Rules::SCHEMA_CAN_RESOLVE_TYPES, Rules::SCHEMA_CAN_FETCH_IDS, Rules::SCHEMA_CAN_GENERATE_IDS, ], } end end end