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

  result = schema.execute(
    document: IntrospectionDocument,
    operation_name: "IntrospectionQuery",
    variables: {},
    context: context
  ).to_h

  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.from_introspection(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, freeze: true))
    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 93
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
  if schema.is_a?(Class)
    @possible_types = schema.possible_types
  end
  @types = Schema.generate(@schema)
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 293
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 269
def get_type(type_name)
  @schema.get_type(type_name)
end
parse(str, filename = nil, lineno = nil) click to toggle source
# File lib/graphql/client.rb, line 125
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
      fragment = ActiveSupport::Inflector.safe_constantize(const_name)

      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?
      node_with_name = node.merge(name: "__anonymous__")
      doc = doc.replace_child(node, node_with_name)
    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 = sliced_definitions(document_dependencies, doc, source_location: source_location)

  visitor = RenameNodeVisitor.new(document_dependencies, definitions: definitions)
  visitor.visit

  if document_tracking_enabled
    @document = @document.merge(definitions: @document.definitions + doc.definitions)
  end

  if definitions["__anonymous__"]
    definitions["__anonymous__"]
  else
    Module.new do
      definitions.each do |name, definition|
        const_set(name, definition)
      end
    end
  end
end
possible_types(type_condition = nil) click to toggle source

A cache of the schema's merged possible types @param type_condition [Class, String] a type definition or type name

# File lib/graphql/client.rb, line 108
def possible_types(type_condition = nil)
  if type_condition
    if defined?(@possible_types)
      if type_condition.respond_to?(:graphql_name)
        type_condition = type_condition.graphql_name
      end
      @possible_types[type_condition]
    else
      @schema.possible_types(type_condition)
    end
  elsif defined?(@possible_types)
    @possible_types
  else
    @schema.possible_types(type_condition)
  end
end
query(definition, variables: {}, context: {}) click to toggle source
# File lib/graphql/client.rb, line 332
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

  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

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

Private Instance Methods

deep_freeze_json_object(obj) click to toggle source
# File lib/graphql/client.rb, line 436
def 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
deep_stringify_keys(obj) click to toggle source
# File lib/graphql/client.rb, line 449
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
find_definition_dependencies(node) click to toggle source
# File lib/graphql/client.rb, line 428
def find_definition_dependencies(node)
  names = []
  visitor = Language::Visitor.new(node)
  visitor[Language::Nodes::FragmentSpread] << -> (node, parent) { names << node.name }
  visitor.visit
  names.uniq
end
sliced_definitions(document_dependencies, doc, source_location:) click to toggle source
# File lib/graphql/client.rb, line 398
def sliced_definitions(document_dependencies, doc, source_location:)
  dependencies = document_dependencies.definitions.map do |node|
    [node.name, find_definition_dependencies(node)]
  end.to_h

  doc.definitions.map do |node|
    deps = Set.new
    definitions = document_dependencies.definitions.map { |x| [x.name, x] }.to_h

    queue = [node.name]
    while name = queue.shift
      next if deps.include?(name)
      deps.add(name)
      queue.concat dependencies[name]
    end

    definitions = document_dependencies.definitions.select { |x| deps.include?(x.name)  }
    sliced_document = Language::Nodes::Document.new(definitions: definitions)
    definition = Definition.for(
      client: self,
      ast_node: node,
      document: sliced_document,
      source_document: doc,
      source_location: source_location
    )

    [node.name, definition]
  end.to_h
end