graphql-client icon indicating copy to clipboard operation
graphql-client copied to clipboard

Any recommendations for configuring host dynamically for testing or development?

Open worace opened this issue 5 years ago • 3 comments

The layout and API of this gem encourage the use of constants to store loaded schemas, client objects, and parsed queries (there's even a special error to flag violations of these). I believe the motivation for this is to save developers from accidentally incurring the cost of parsing schemas or queries more often than is necessary, and to raise any validation errors from parsing an invalid schema as early as possible.

However there's one use-case that I've found becomes tricky with this setup, which is customizing the host on the fly. My instinct, based on how I've seen other Ruby API client libraries work in the past, would have been to take something like this:

  HTTP = GraphQL::Client::HTTP.new("https://example.com/graphql") do
    def headers(context)
      # Optionally set any HTTP headers
      { "User-Agent": "My Client" }
    end
  end  

and wrap it in a client object which could take the host as a constructor parameter. But obviously that doesn't work as well when everything is bootstrapped at require time.

Does anyone have tips on how to handle this problem? Do you just have to configure it through some other means that can be toggled at load-time (like a config file or an env variable or something)? Curious how others are handling test/dev/prod-type of host switching for these clients without duplicating too much of the code in order to provide multiple versions.

worace avatar Aug 24 '20 16:08 worace

Just had to work around the same thing, and I agree the design is problematic in this case. I ended up subclassing the transport to hack around it a bit:

  class ContextDrivenTransport < GraphQL::Client::HTTP
    def initialize
      super("https://localhost:1234")
    end

    def headers(context)
      {
        'Authorization' => "Bearer #{context.fetch(:api_key)}"
      }
    end

    def execute(document:, operation_name: nil, variables: {}, context: {})
      @uri = context.fetch(:base_uri) + '/graphql'

      super(document: document, operation_name: operation_name, variables: variables, context: context)
    end
  end

This requires that my actual queries have context for the base uri as well as the api key to authenticate, which I then wrapped in a handy method on my Client wrapper class:

class MyClient
  Schema = GraphQL::Client.load_schema(File.join(__dir__, 'graphql', 'schema.json'))

  Client = GraphQL::Client.new(schema: Schema, execute: ContextDrivenTransport.new)

  Queries = Client.parse('... some queries here ...')

  def initialize(base_uri, api_key)
    @base_uri = URI.parse(base_uri)
    @api_key = api_key
  end

  def query(query, variables: {})
    Client.query(query, variables: variables, context: { api_key: @api_key, base_uri: @base_uri })
  end
end

(Slimmed down the example, my client implements some methods to make simple queries trivial as well, but you get the idea.)

Hope that helps!

askreet avatar Oct 09 '20 15:10 askreet

and I agree the design is problematic in this case

Especially in cases like Shopify (which had their own problems) where each shop has their own endpoint but the underlying GQL schema is the same.

It's confusing and, really what's the upside to this small headache?

I ultimately opted for:

class GQL
  #
  # <snip> unrelated stuff
  #
  def initialize(endpoint, headers = nil)
    raise ArgumentError, "endpoint required" if endpoint.to_s.strip.empty?

    @endpoint = endpoint
    @headers = headers || {}
  end

  def query
    post(query)
  end

  private

  def parse_json(data)
    JSON.parse(data)
  rescue JSON::ParserError => e
    error("cannot parse JSON #{e}")
  end

  def error(message)
    raise Error, "GraphQL query to #@endpoint failed: #{message}"
  end

  def post(query)
    begin
      response = Net::HTTP.post(@endpoint, query, @headers)
    rescue => e
      error(e.message)
    end

    error("#{response.body[0..100]}") if response.code != "200"

    json = parse_json(response.body)
    # In my cases there will never be "data" and "errors"
    if json.include?("errors")
      error(json["errors"].map { |e| e["message"] }.to_s)
    end

    json
  end
end

Please tell me why this is bad?

sshaw avatar Nov 26 '20 04:11 sshaw

and I agree the design is problematic in this case

Especially in cases like Shopify (which had their own problems) where each shop has their own endpoint but the underlying GQL schema is the same.

It's confusing and, really what's the upside to this small headache?

I ultimately opted for:

class GQL
  #
  # <snip> unrelated stuff
  #
  def initialize(endpoint, headers = nil)
    raise ArgumentError, "endpoint required" if endpoint.to_s.strip.empty?

    @endpoint = endpoint
    @headers = headers || {}
  end

  def query
    post(query)
  end

  private

  def parse_json(data)
    JSON.parse(data)
  rescue JSON::ParserError => e
    error("cannot parse JSON #{e}")
  end

  def error(message)
    raise Error, "GraphQL query to #@endpoint failed: #{message}"
  end

  def post(query)
    begin
      response = Net::HTTP.post(@endpoint, query, @headers)
    rescue => e
      error(e.message)
    end

    error("#{response.body[0..100]}") if response.code != "200"

    json = parse_json(response.body)
    # In my cases there will never be "data" and "errors"
    if json.include?("errors")
      error(json["errors"].map { |e| e["message"] }.to_s)
    end

    json
  end
end

Please tell me why this is bad?

I think there is nothing wrong with it, you are simply skipping the grahql-client validations that check that queries comply with the GraphQL and schema specifications.

pjmartorell avatar Apr 17 '21 16:04 pjmartorell