Class: Gitlab::Triage::NetworkAdapters::GraphqlAdapter

Inherits:
BaseAdapter
  • Object
show all
Defined in:
lib/gitlab/triage/network_adapters/graphql_adapter.rb

Defined Under Namespace

Classes: RawDocument

Constant Summary collapse

Client =
GraphQL::Client

Constants inherited from BaseAdapter

BaseAdapter::USER_AGENT

Instance Attribute Summary

Attributes inherited from BaseAdapter

#options, #tracker

Instance Method Summary collapse

Methods inherited from BaseAdapter

#initialize

Constructor Details

This class inherits a constructor from Gitlab::Triage::NetworkAdapters::BaseAdapter

Instance Method Details

#build_graphql_response(node, headers) ⇒ Object (private)

Shared response assembly for both #query and #mutate (parsed path) and #query_raw (raw path). Takes a plain-Ruby node (Hash/Array/nil) and the rate-limit headers, and produces the canonical response shape: { ratelimit_*, [more_pages, end_cursor], results }.



79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# File 'lib/gitlab/triage/network_adapters/graphql_adapter.rb', line 79

def build_graphql_response(node, headers)
  response = {
    ratelimit_remaining: rate_limit_remaining(headers),
    ratelimit_reset_at: rate_limit_reset_at(headers)
  }

  return response.merge(results: {}) if node.nil?

  if node.is_a?(Hash) && node.key?('nodes')
    page_info = node['pageInfo'] || {}
    response.merge(
      more_pages: page_info['hasNextPage'] || false,
      end_cursor: page_info['endCursor'],
      results: node['nodes']
    )
  else
    response.merge(results: node)
  end
end

#clientObject (private)



176
177
178
# File 'lib/gitlab/triage/network_adapters/graphql_adapter.rb', line 176

def client
  @client ||= Client.new(schema: schema, execute: http_client).tap { |client| client.allow_dynamic_queries = true }
end

#http_clientObject (private)



140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
# File 'lib/gitlab/triage/network_adapters/graphql_adapter.rb', line 140

def http_client
  graphql_tracker = tracker
  graphql_url = "#{options.host_url}/api/graphql"

  Client::HTTP.new(graphql_url) do
    define_method(:execute) do |document:, operation_name: nil, variables: {}, context: {}|
      body = {}
      body['query'] = document.to_query_string
      body['variables'] = variables if variables.any?
      body['operationName'] = operation_name if operation_name

      response = HTTParty.post(
        uri,
        body: body.to_json,
        headers: {
          'User-Agent' => USER_AGENT,
          'Content-type' => 'application/json',
          'PRIVATE-TOKEN' => context[:token]
        }
      )

      case response.code
      when 200, 400
        graphql_tracker&.record('POST', graphql_url)
        JSON.parse(response.body).merge('extensions' => { 'headers' => response.headers })
      else
        { 'errors' => [{ 'message' => "#{response.code} #{response.message}" }] }
      end
    end
  end
end

#mutate(graphql_mutation, variables: {}) ⇒ Object



56
57
58
59
60
61
62
63
64
65
66
67
68
69
# File 'lib/gitlab/triage/network_adapters/graphql_adapter.rb', line 56

def mutate(graphql_mutation, variables: {})
  response = client.query(graphql_mutation, variables: variables, context: { token: options.token })

  raise_on_error!(response)

  parsed_response = response.data
  headers = response.extensions.fetch('headers', {})

  {
    ratelimit_remaining: headers['ratelimit-remaining'].to_i,
    ratelimit_reset_at: Time.at(headers['ratelimit-reset'].to_i),
    results: parsed_response.to_h
  }
end

#parse_response(response, resource_path) ⇒ Object (private)



128
129
130
# File 'lib/gitlab/triage/network_adapters/graphql_adapter.rb', line 128

def parse_response(response, resource_path)
  resource_path.reduce(response.data) { |data, resource| data&.send(resource) } # rubocop:disable GitlabSecurity/PublicSend
end

#plain_node(parsed_response) ⇒ Object (private)

Converts a parsed GraphQL::Client response node into plain Ruby so the shared builder only ever deals with Hash/Array, never client objects.



114
115
116
117
118
119
120
121
122
123
124
125
126
# File 'lib/gitlab/triage/network_adapters/graphql_adapter.rb', line 114

def plain_node(parsed_response)
  return if parsed_response.nil?
  return parsed_response.map(&:to_h) if parsed_response.is_a?(Client::List)
  return parsed_response.to_h unless parsed_response.nodes?

  {
    'nodes' => parsed_response.nodes.map(&:to_h),
    'pageInfo' => {
      'hasNextPage' => parsed_response.page_info.has_next_page,
      'endCursor' => parsed_response.page_info.end_cursor
    }
  }
end

#query(graphql_query, resource_path: [], variables: {}) ⇒ Object



22
23
24
25
26
27
28
29
30
31
# File 'lib/gitlab/triage/network_adapters/graphql_adapter.rb', line 22

def query(graphql_query, resource_path: [], variables: {})
  response = client.query(graphql_query, variables: variables, context: { token: options.token })

  raise_on_error!(response)

  headers = response.extensions.fetch('headers', {})
  node = plain_node(parse_response(response, resource_path))

  build_graphql_response(node, headers)
end

#query_raw(query_string, resource_path: [], variables: {}) ⇒ Object

Executes a raw query string directly through the HTTP layer, skipping GraphQL::Client schema validation (client.parse). Required for queries that reference experiment-tagged fields/arguments: GitLab hides those from introspection but executes them at runtime, so the client-side validator would otherwise reject a perfectly valid query.

Returns the same shape as #query so callers can be source-agnostic.



40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# File 'lib/gitlab/triage/network_adapters/graphql_adapter.rb', line 40

def query_raw(query_string, resource_path: [], variables: {})
  raw = http_client.execute(
    document: RawDocument.new(query_string),
    variables: variables,
    context: { token: options.token }
  )

  errors = raw['errors']
  raise "There was an error: #{errors.to_json}" if errors.present?

  headers = raw.dig('extensions', 'headers') || {}
  node = resource_path.reduce(raw['data']) { |data, segment| data&.fetch(segment, nil) }

  build_graphql_response(node, headers)
end

#raise_on_error!(response) ⇒ Object (private)



132
133
134
135
136
137
138
# File 'lib/gitlab/triage/network_adapters/graphql_adapter.rb', line 132

def raise_on_error!(response)
  return if response.errors.blank?

  puts Gitlab::Triage::UI.debug response.inspect if options.debug

  raise "There was an error: #{response.errors.messages.to_json}"
end

#rate_limit_remaining(headers) ⇒ Object (private)

When the rate-limit headers are absent (e.g. a server that doesn't send them), treat the limit as not-a-concern rather than coercing a missing header to 0/1970, which would otherwise trip rate_limit_wait.



102
103
104
105
# File 'lib/gitlab/triage/network_adapters/graphql_adapter.rb', line 102

def rate_limit_remaining(headers)
  value = headers['ratelimit-remaining']
  value.present? ? value.to_i : Float::INFINITY
end

#rate_limit_reset_at(headers) ⇒ Object (private)



107
108
109
110
# File 'lib/gitlab/triage/network_adapters/graphql_adapter.rb', line 107

def rate_limit_reset_at(headers)
  value = headers['ratelimit-reset']
  Time.at(value.to_i) if value.present?
end

#schemaObject (private)



172
173
174
# File 'lib/gitlab/triage/network_adapters/graphql_adapter.rb', line 172

def schema
  @schema ||= Client.load_schema(http_client)
end