Skip to content

Commit

Permalink
Improved GraphQL client
Browse files Browse the repository at this point in the history
This is a complete rewrite of the GraphQL client functionality.

The previous implementation suffered from a few problems making it
virtually unusable as detailed in
#511. Here's a summary:

1. you couldn't specify a local schema file
2. due to the above, the client made a dynamic introspection request on
initialization which was very slow
3. unbounded memory growth due to building new clients at runtime

This rewrite was focused on solving those problems first and foremost
but we also had a few other goals:

* support API versioning
* provide better defaults and an improved developer experience
* ensure it's impossible to do the wrong thing

The new GraphQL client *only* supports loading local schema files to
ensure no introspection requests are made during app runtime. The goal
is that clients are fully initialized at application boot time (and if
you're using Rails this is handled automatically).

Workflow:
1. Set `ShopifyAPI::GraphQL.schema_location` to a directory path (or
use the default in Rails of `db/shopify_graphql_schemas`).
2. Save a JSON version of Shopify's Admin schema locally (or use the
`shopify_api:graphql:dump` Rake task) to the `schema_location` and name
it after the API version: `2020-01.json`.
3. Access the client at `ShopifyAPI::GraphQL.client`
4. Execute queries via `client.query`
  • Loading branch information
swalkinshaw committed Jan 20, 2020
1 parent 2818c3b commit 45dd353
Show file tree
Hide file tree
Showing 12 changed files with 2,531 additions and 23 deletions.
2 changes: 2 additions & 0 deletions lib/shopify_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ module ShopifyAPI
require 'shopify_api/message_enricher'
require 'shopify_api/connection'
require 'shopify_api/pagination_link_headers'
require 'shopify_api/graphql'
require 'shopify_api/graphql/railtie' if defined?(Rails)

if ShopifyAPI::Base.respond_to?(:connection_class)
ShopifyAPI::Base.connection_class = ShopifyAPI::Connection
Expand Down
2 changes: 1 addition & 1 deletion lib/shopify_api/api_version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ class UnknownVersion < StandardError; end
class ApiVersionNotSetError < StandardError; end
include Comparable

HANDLE_FORMAT = /^\d{4}-\d{2}$/.freeze
UNSTABLE_HANDLE = 'unstable'
HANDLE_FORMAT = /((\d{4}-\d{2})|#{UNSTABLE_HANDLE})/.freeze
UNSTABLE_AS_DATE = Time.utc(3000, 1, 1)
API_PREFIX = '/admin/api/'
LOOKUP_MODES = [:raise_on_unknown, :define_on_unknown].freeze
Expand Down
80 changes: 80 additions & 0 deletions lib/shopify_api/graphql.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# frozen_string_literal: true
require 'graphql/client'
require 'shopify_api/graphql/http_client'

module ShopifyAPI
module GraphQL
DEFAULT_SCHEMA_LOCATION_PATH = Pathname('shopify_graphql_schemas')

InvalidSchema = Class.new(StandardError)
InvalidSchemaLocation = Class.new(StandardError)
InvalidClient = Class.new(StandardError)

class << self
delegate :parse, :query, to: :client

def client(api_version = ShopifyAPI::Base.api_version.handle)
initialize_client_cache
cached_client = @_client_cache[api_version]

if cached_client
cached_client
else
schema_file = schema_location.join("#{api_version}.json")

if !schema_file.exist?
raise InvalidClient, <<~MSG
Client for API version #{api_version} does not exist because no schema file exists at `#{schema_file}`.
To dump the schema file, use the `rake shopify_api:graphql:dump` task.
MSG
else
puts '[WARNING] Client was not pre-initialized. Ensure `ShopifyAPI::GraphQL.initialize_clients` is called during app initialization.'
initialize_clients
@_client_cache[api_version]
end
end
end

def clear_clients
@_client_cache = {}
end

def initialize_clients
initialize_client_cache

Dir.glob(schema_location.join("*.json")).each do |schema_file|
schema_file = Pathname(schema_file)
matches = schema_file.basename.to_s.match(/^#{ShopifyAPI::ApiVersion::HANDLE_FORMAT}\.json$/)

if matches
api_version = ShopifyAPI::ApiVersion.new(handle: matches[1])
else
raise InvalidSchema, "Invalid schema file name `#{schema_file}`. Does not match format of: `<version>.json`."
end

schema = ::GraphQL::Client.load_schema(schema_file.to_s)
client = ::GraphQL::Client.new(schema: schema, execute: HTTPClient.new(api_version)).tap do |c|
c.allow_dynamic_queries = true
end

@_client_cache[api_version.handle] = client
end
end

def schema_location
@schema_location || DEFAULT_SCHEMA_LOCATION_PATH
end

def schema_location=(path)
@schema_location = Pathname(path)
end

private

def initialize_client_cache
@_client_cache ||= {}
end
end
end
end
22 changes: 22 additions & 0 deletions lib/shopify_api/graphql/http_client.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true
require 'graphql/client/http'

module ShopifyAPI
module GraphQL
class HTTPClient < ::GraphQL::Client::HTTP
def initialize(api_version)
@api_version = api_version
end

def headers(_context)
ShopifyAPI::Base.headers
end

def uri
ShopifyAPI::Base.site.dup.tap do |uri|
uri.path = @api_version.construct_graphql_path
end
end
end
end
end
17 changes: 17 additions & 0 deletions lib/shopify_api/graphql/railtie.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# frozen_string_literal: true
require 'rails/railtie'

module ShopifyAPI
module GraphQL
class Railtie < Rails::Railtie
initializer 'shopify_api.initialize_graphql_clients' do |app|
ShopifyAPI::GraphQL.schema_location = app.root.join('db', ShopifyAPI::GraphQL.schema_location)
ShopifyAPI::GraphQL.initialize_clients
end

rake_tasks do
load 'shopify_api/graphql/task.rake'
end
end
end
end
54 changes: 54 additions & 0 deletions lib/shopify_api/graphql/task.rake
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# frozen_string_literal: true
require 'fileutils'

namespace :shopify_api do
namespace :graphql do
prereqs = []
# add the Rails environment task as a prerequisite if loaded from a Rails app
prereqs << :environment if Rake::Task.task_defined?('environment')

desc 'Writes the Shopify Admin API GraphQL schema to a local file'
task dump: prereqs do
site_url = ENV['SITE_URL'] || ENV['site_url']
shop_domain = ENV['SHOP_DOMAIN'] || ENV['shop_domain']
api_version = ENV['API_VERSION'] || ENV['api_version']
access_token = ENV['ACCESS_TOKEN'] || ENV['access_token']

unless site_url || shop_domain
puts 'Either SHOP_DOMAIN or SITE_URL is required for authentication'
exit(1)
end

if site_url && shop_domain
puts 'SHOP_DOMAIN and SITE_URL cannot be used together. Use one or the other for authentication.'
exit(1)
end

if shop_domain && !access_token
puts 'ACCESS_TOKEN required when SHOP_DOMAIN is used'
exit(1)
end

unless api_version
puts "API_VERSION required. Example `2020-01`"
exit(1)
end

ShopifyAPI::ApiVersion.fetch_known_versions
ShopifyAPI::ApiVersion.version_lookup_mode = :raise_on_unknown

shopify_session = ShopifyAPI::Session.new(domain: shop_domain, token: access_token, api_version: api_version)
ShopifyAPI::Base.activate_session(shopify_session)

if site_url
ShopifyAPI::Base.site = site_url
end

schema_location = ShopifyAPI::GraphQL.schema_location
FileUtils.mkdir_p(schema_location) unless Dir.exist?(schema_location)

client = ShopifyAPI::GraphQL::HTTPClient.new(ShopifyAPI::Base.api_version.handle)
GraphQL::Client.dump_schema(client, schema_location.join("#{api_version}.json").to_s)
end
end
end
22 changes: 0 additions & 22 deletions lib/shopify_api/resources/graphql.rb

This file was deleted.

Loading

0 comments on commit 45dd353

Please sign in to comment.