class GraphQL::Client

GraphQL Client helps build and execute queries against a GraphQL backend.

A client instance SHOULD be configured with a schema to enable query validation. And SHOULD also be configured with a backend “execute” adapter to point at a remote GraphQL HTTP service or execute directly against a Schema object.

Constants

Erubis

Public: Extended Erubis implementation that supports GraphQL static query sections.

<%graphql
  query GetVersion {
    version
  }
%>
<%= data.version %>

Configure ActionView's default ERB implementation to use this class.

ActionView::Template::Handlers::ERB.erb_implementation = GraphQL::Client::Erubis

Public: Extended Erubis implementation that supports GraphQL static query sections.

<%graphql
  query GetVerison {
    version
  }
%>
<%= data.version %>

Configure ActionView's default ERB implementation to use this class.

ActionView::Template::Handlers::ERB.erb_implementation = GraphQL::Client::Erubi
IntrospectionDocument
WHITELISTED_GEM_NAMES

Collocation will not be enforced if a stack trace includes any of these gems.

Attributes

allow_dynamic_queries[RW]

Deprecated: Allow dynamically generated queries to be passed to Client#query.

This ability will eventually be removed in future versions.

document[R]
document_tracking_enabled[RW]
enforce_collocated_callers[R]

Public: Check if collocated caller enforcement is enabled.

execute[R]
schema[R]
types[R]

Public Class Methods

dump_schema(schema, io = nil, context: {}) click to toggle source
# File lib/graphql/client.rb, line 72
def self.dump_schema(schema, io = nil, context: {})
  unless schema.respond_to?(:execute)
    raise TypeError, "expected schema to respond to #execute(), but was #{schema.class}"
  end

  payload = {
    document: IntrospectionDocument,
    operation_name: "IntrospectionQuery",
    variables: {},
    context: context
  }

  result = schema.execute(payload).to_h

  _, errors, __ = eval_query_result(payload, result)
  if errors.any?
    errors_detail = errors.map { |e| e["message"] }.join("; ")
    raise QueryError, "The query returned an error (#{errors_detail})"
  end

  if io
    io = File.open(io, "w") if io.is_a?(String)
    io.write(JSON.pretty_generate(result))
    io.close_write
  end

  result
end
load_schema(schema) click to toggle source
# File lib/graphql/client.rb, line 47
def self.load_schema(schema)
  case schema
  when GraphQL::Schema, Class
    schema
  when Hash
    GraphQL::Schema::Loader.load(schema)
  when String
    if schema.end_with?(".json") && File.exist?(schema)
      load_schema(File.read(schema))
    elsif schema =~ /\A\s*{/
      load_schema(JSON.parse(schema))
    end
  else
    if schema.respond_to?(:execute)
      load_schema(dump_schema(schema))
    elsif schema.respond_to?(:to_h)
      load_schema(schema.to_h)
    else
      nil
    end
  end
end
new(schema:, execute: nil, enforce_collocated_callers: false) click to toggle source
# File lib/graphql/client.rb, line 101
def initialize(schema:, execute: nil, enforce_collocated_callers: false)
  @schema = self.class.load_schema(schema)
  @execute = execute
  @document = GraphQL::Language::Nodes::Document.new(definitions: [])
  @document_tracking_enabled = false
  @allow_dynamic_queries = false
  @enforce_collocated_callers = enforce_collocated_callers

  @types = Schema.generate(@schema)
end

Private Class Methods

deep_freeze_json_object(obj) click to toggle source
# File lib/graphql/client.rb, line 440
def self.deep_freeze_json_object(obj)
  case obj
  when String
    obj.freeze
  when Array
    obj.each { |v| deep_freeze_json_object(v) }
    obj.freeze
  when Hash
    obj.each { |k, v| k.freeze; deep_freeze_json_object(v) }
    obj.freeze
  end
end
eval_query_result(payload, result) click to toggle source
# File lib/graphql/client.rb, line 424
def self.eval_query_result(payload, result)
  deep_freeze_json_object(result)

  data, errors, extensions = result.values_at("data", "errors", "extensions")

  errors ||= []
  errors = errors.map(&:dup)
  GraphQL::Client::Errors.normalize_error_paths(data, errors)

  errors.each do |error|
    error_payload = payload.merge(message: error["message"], error: error)
    ActiveSupport::Notifications.instrument("error.graphql", error_payload)
  end
  [data, errors, extensions]
end

Public Instance Methods

create_operation(fragment, filename = nil, lineno = nil) click to toggle source

Public: Create operation definition from a fragment definition.

Automatically determines operation variable set.

Examples

FooFragment = Client.parse <<-'GRAPHQL'
  fragment on Mutation {
    updateFoo(id: $id, content: $content)
  }
GRAPHQL

# mutation($id: ID!, $content: String!) {
#   updateFoo(id: $id, content: $content)
# }
FooMutation = Client.create_operation(FooFragment)

fragment - A FragmentDefinition definition.

Returns an OperationDefinition.

# File lib/graphql/client.rb, line 330
def create_operation(fragment, filename = nil, lineno = nil)
  unless fragment.is_a?(GraphQL::Client::FragmentDefinition)
    raise TypeError, "expected fragment to be a GraphQL::Client::FragmentDefinition, but was #{fragment.class}"
  end

  if filename.nil? && lineno.nil?
    location = caller_locations(1, 1).first
    filename = location.path
    lineno = location.lineno
  end

  variables = GraphQL::Client::DefinitionVariables.operation_variables(self.schema, fragment.document, fragment.definition_name)
  type_name = fragment.definition_node.type.name

  if schema.query && type_name == schema.query.graphql_name
    operation_type = "query"
  elsif schema.mutation && type_name == schema.mutation.graphql_name
    operation_type = "mutation"
  elsif schema.subscription && type_name == schema.subscription.graphql_name
    operation_type = "subscription"
  else
    types = [schema.query, schema.mutation, schema.subscription].compact
    raise Error, "Fragment must be defined on #{types.map(&:graphql_name).join(", ")}"
  end

  doc_ast = GraphQL::Language::Nodes::Document.new(definitions: [
    GraphQL::Language::Nodes::OperationDefinition.new(
      operation_type: operation_type,
      variables: variables,
      selections: [
        GraphQL::Language::Nodes::FragmentSpread.new(name: fragment.name)
      ]
    )
  ])
  parse(doc_ast.to_query_string, filename, lineno)
end
get_type(type_name) click to toggle source

Public: A wrapper to use the more-efficient `.get_type` when it's available from GraphQL-Ruby (1.10+)

# File lib/graphql/client.rb, line 302
def get_type(type_name)
  if @schema.respond_to?(:get_type)
    @schema.get_type(type_name)
  else
    @schema.types[type_name]
  end
end
parse(str, filename = nil, lineno = nil) click to toggle source
# File lib/graphql/client.rb, line 112
def parse(str, filename = nil, lineno = nil)
  if filename.nil? && lineno.nil?
    location = caller_locations(1, 1).first
    filename = location.path
    lineno = location.lineno
  end

  unless filename.is_a?(String)
    raise TypeError, "expected filename to be a String, but was #{filename.class}"
  end

  unless lineno.is_a?(Integer)
    raise TypeError, "expected lineno to be a Integer, but was #{lineno.class}"
  end

  source_location = [filename, lineno].freeze

  definition_dependencies = Set.new

  # Replace Ruby constant reference with GraphQL fragment names,
  # while populating `definition_dependencies` with
  # GraphQL Fragment ASTs which this operation depends on
  str = str.gsub(/\.\.\.([a-zA-Z0-9_]+(::[a-zA-Z0-9_]+)*)/) do
    match = Regexp.last_match
    const_name = match[1]

    if str.match(/fragment\s*#{const_name}/)
      # It's a fragment _definition_, not a fragment usage
      match[0]
    else
      # It's a fragment spread, so we should load the fragment
      # which corresponds to the spread.
      # We depend on ActiveSupport to either find the already-loaded
      # constant, or to load the constant by name
      begin
        fragment = ActiveSupport::Inflector.constantize(const_name)
      rescue NameError
        fragment = nil
      end

      case fragment
      when FragmentDefinition
        # We found the fragment definition that this fragment spread belongs to.
        # So, register the AST of this fragment in `definition_dependencies`
        # and update the query string to valid GraphQL syntax,
        # replacing the Ruby constant
        definition_dependencies.merge(fragment.document.definitions)
        "...#{fragment.definition_name}"
      else
        if fragment
          message = "expected #{const_name} to be a #{FragmentDefinition}, but was a #{fragment.class}."
          if fragment.is_a?(Module) && fragment.constants.any?
            message += " Did you mean #{fragment}::#{fragment.constants.first}?"
          end
        else
          message = "uninitialized constant #{const_name}"
        end

        error = ValidationError.new(message)
        error.set_backtrace(["#{filename}:#{lineno + match.pre_match.count("\n") + 1}"] + caller)
        raise error
      end
    end
  end

  doc = GraphQL.parse(str)

  document_types = DocumentTypes.analyze_types(self.schema, doc).freeze
  doc = QueryTypename.insert_typename_fields(doc, types: document_types)

  doc.definitions.each do |node|
    if node.name.nil?
      if node.respond_to?(:merge) # GraphQL 1.9 +
        node_with_name = node.merge(name: "__anonymous__")
        doc = doc.replace_child(node, node_with_name)
      else
        node.name = "__anonymous__"
      end
    end
  end

  document_dependencies = Language::Nodes::Document.new(definitions: doc.definitions + definition_dependencies.to_a)

  rules = GraphQL::StaticValidation::ALL_RULES - [
    GraphQL::StaticValidation::FragmentsAreUsed,
    GraphQL::StaticValidation::FieldsHaveAppropriateSelections
  ]
  validator = GraphQL::StaticValidation::Validator.new(schema: self.schema, rules: rules)
  query = GraphQL::Query.new(self.schema, document: document_dependencies)

  errors = validator.validate(query)
  errors.fetch(:errors).each do |error|
    error_hash = error.to_h
    validation_line = error_hash["locations"][0]["line"]
    error = ValidationError.new(error_hash["message"])
    error.set_backtrace(["#{filename}:#{lineno + validation_line}"] + caller)
    raise error
  end

  definitions = {}
  doc.definitions.each do |node|
    sliced_document = Language::DefinitionSlice.slice(document_dependencies, node.name)
    definition = Definition.for(
      client: self,
      ast_node: node,
      document: sliced_document,
      source_document: doc,
      source_location: source_location
    )
    definitions[node.name] = definition
  end

  if @document.respond_to?(:merge) # GraphQL 1.9+
    visitor = RenameNodeVisitor.new(document_dependencies, definitions: definitions)
    visitor.visit
  else
    name_hook = RenameNodeHook.new(definitions)
    visitor = Language::Visitor.new(document_dependencies)
    visitor[Language::Nodes::FragmentDefinition].leave << name_hook.method(:rename_node)
    visitor[Language::Nodes::OperationDefinition].leave << name_hook.method(:rename_node)
    visitor[Language::Nodes::FragmentSpread].leave << name_hook.method(:rename_node)
    visitor.visit
  end

  if document_tracking_enabled
    if @document.respond_to?(:merge) # GraphQL 1.9+
      @document = @document.merge(definitions: @document.definitions + doc.definitions)
    else
      @document.definitions.concat(doc.definitions)
    end
  end

  if definitions["__anonymous__"]
    definitions["__anonymous__"]
  else
    Module.new do
      definitions.each do |name, definition|
        const_set(name, definition)
      end
    end
  end
end
query(definition, variables: {}, context: {}) click to toggle source
# File lib/graphql/client.rb, line 369
def query(definition, variables: {}, context: {})
  raise NotImplementedError, "client network execution not configured" unless execute

  unless definition.is_a?(OperationDefinition)
    raise TypeError, "expected definition to be a #{OperationDefinition.name} but was #{document.class.name}"
  end

  if allow_dynamic_queries == false && definition.name.nil?
    raise DynamicQueryError, "expected definition to be assigned to a static constant https://git.io/vXXSE"
  end

  variables = deep_stringify_keys(variables)

  document = definition.document
  operation = definition.definition_node

  payload = {
    document: document,
    operation_name: operation.name,
    operation_type: operation.operation_type,
    variables: variables,
    context: context
  }

  result = ActiveSupport::Notifications.instrument("query.graphql", payload) do
    execute.execute(
      document: document,
      operation_name: operation.name,
      variables: variables,
      context: context
    )
  end

  data, errors, extensions = self.class.eval_query_result(payload, result)

  Response.new(
    result,
    data: definition.new(data, Errors.new(errors, ["data"])),
    errors: Errors.new(errors),
    extensions: extensions
  )
end

Private Instance Methods

deep_stringify_keys(obj) click to toggle source
# File lib/graphql/client.rb, line 453
def deep_stringify_keys(obj)
  case obj
  when Hash
    obj.each_with_object({}) do |(k, v), h|
      h[k.to_s] = deep_stringify_keys(v)
    end
  else
    obj
  end
end