From 8883785fe8cf0578321d0a92973663d26529d6b3 Mon Sep 17 00:00:00 2001 From: Scott Walkinshaw Date: Thu, 16 Jan 2020 12:56:56 -0500 Subject: [PATCH 1/3] Improved GraphQL client 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 https://github.com/Shopify/shopify_api/issues/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` --- lib/shopify_api.rb | 2 + lib/shopify_api/api_version.rb | 2 +- lib/shopify_api/graphql.rb | 79 ++ lib/shopify_api/graphql/http_client.rb | 22 + lib/shopify_api/graphql/railtie.rb | 17 + lib/shopify_api/graphql/task.rake | 54 ++ lib/shopify_api/resources/graphql.rb | 22 - test/fixtures/graphql/2019-10.json | 1083 ++++++++++++++++++++++++ test/fixtures/graphql/dummy_schema.rb | 16 + test/fixtures/graphql/unstable.json | 1083 ++++++++++++++++++++++++ test/graphql/http_client_test.rb | 26 + test/graphql_test.rb | 147 ++++ 12 files changed, 2530 insertions(+), 23 deletions(-) create mode 100644 lib/shopify_api/graphql.rb create mode 100644 lib/shopify_api/graphql/http_client.rb create mode 100644 lib/shopify_api/graphql/railtie.rb create mode 100644 lib/shopify_api/graphql/task.rake delete mode 100644 lib/shopify_api/resources/graphql.rb create mode 100644 test/fixtures/graphql/2019-10.json create mode 100644 test/fixtures/graphql/dummy_schema.rb create mode 100644 test/fixtures/graphql/unstable.json create mode 100644 test/graphql/http_client_test.rb create mode 100644 test/graphql_test.rb diff --git a/lib/shopify_api.rb b/lib/shopify_api.rb index 108242c59..a113edc04 100644 --- a/lib/shopify_api.rb +++ b/lib/shopify_api.rb @@ -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 diff --git a/lib/shopify_api/api_version.rb b/lib/shopify_api/api_version.rb index 41e4f7aea..d4b719037 100644 --- a/lib/shopify_api/api_version.rb +++ b/lib/shopify_api/api_version.rb @@ -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 diff --git a/lib/shopify_api/graphql.rb b/lib/shopify_api/graphql.rb new file mode 100644 index 000000000..67166a0ec --- /dev/null +++ b/lib/shopify_api/graphql.rb @@ -0,0 +1,79 @@ +# 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) + 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: `.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 diff --git a/lib/shopify_api/graphql/http_client.rb b/lib/shopify_api/graphql/http_client.rb new file mode 100644 index 000000000..81b797345 --- /dev/null +++ b/lib/shopify_api/graphql/http_client.rb @@ -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 diff --git a/lib/shopify_api/graphql/railtie.rb b/lib/shopify_api/graphql/railtie.rb new file mode 100644 index 000000000..962c7c5a0 --- /dev/null +++ b/lib/shopify_api/graphql/railtie.rb @@ -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 diff --git a/lib/shopify_api/graphql/task.rake b/lib/shopify_api/graphql/task.rake new file mode 100644 index 000000000..6c465a394 --- /dev/null +++ b/lib/shopify_api/graphql/task.rake @@ -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) + GraphQL::Client.dump_schema(client, schema_location.join("#{api_version}.json").to_s) + end + end +end diff --git a/lib/shopify_api/resources/graphql.rb b/lib/shopify_api/resources/graphql.rb deleted file mode 100644 index e3bedb99e..000000000 --- a/lib/shopify_api/resources/graphql.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true -require 'graphql/client' -require 'graphql/client/http' - -module ShopifyAPI - # GraphQL API. - class GraphQL - def initialize - uri = Base.site.dup - uri.path = Base.api_version.construct_graphql_path - @http = ::GraphQL::Client::HTTP.new(uri.to_s) do - define_method(:headers) do |_context| - Base.headers - end - end - @schema = ::GraphQL::Client.load_schema(@http) - @client = ::GraphQL::Client.new(schema: @schema, execute: @http) - end - - delegate :parse, :query, to: :@client - end -end diff --git a/test/fixtures/graphql/2019-10.json b/test/fixtures/graphql/2019-10.json new file mode 100644 index 000000000..eb718fb7d --- /dev/null +++ b/test/fixtures/graphql/2019-10.json @@ -0,0 +1,1083 @@ +{ + "data": { + "__schema": { + "queryType": { + "name": "QueryRoot" + }, + "mutationType": null, + "subscriptionType": null, + "types": [ + { + "kind": "SCALAR", + "name": "Boolean", + "description": "Represents `true` or `false` values.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Product", + "description": null, + "fields": [ + { + "name": "name", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "QueryRoot", + "description": null, + "fields": [ + { + "name": "product", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Product", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "String", + "description": "Represents textual data as UTF-8 character sequences. This type is most often used by GraphQL to represent free-form human-readable text.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__Directive", + "description": "A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document.\n\nIn some cases, you need to provide options to alter GraphQL's execution behavior in ways field arguments will not suffice, such as conditionally including or skipping a field. Directives provide this by describing additional information to the executor.", + "fields": [ + { + "name": "args", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__InputValue", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "locations", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "__DirectiveLocation", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "onField", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": true, + "deprecationReason": "Use `locations`." + }, + { + "name": "onFragment", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": true, + "deprecationReason": "Use `locations`." + }, + { + "name": "onOperation", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": true, + "deprecationReason": "Use `locations`." + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "__DirectiveLocation", + "description": "A Directive can be adjacent to many parts of the GraphQL language, a __DirectiveLocation describes one such possible adjacencies.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "QUERY", + "description": "Location adjacent to a query operation.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MUTATION", + "description": "Location adjacent to a mutation operation.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SUBSCRIPTION", + "description": "Location adjacent to a subscription operation.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "FIELD", + "description": "Location adjacent to a field.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "FRAGMENT_DEFINITION", + "description": "Location adjacent to a fragment definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "FRAGMENT_SPREAD", + "description": "Location adjacent to a fragment spread.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INLINE_FRAGMENT", + "description": "Location adjacent to an inline fragment.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SCHEMA", + "description": "Location adjacent to a schema definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SCALAR", + "description": "Location adjacent to a scalar definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "OBJECT", + "description": "Location adjacent to an object type definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "FIELD_DEFINITION", + "description": "Location adjacent to a field definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ARGUMENT_DEFINITION", + "description": "Location adjacent to an argument definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INTERFACE", + "description": "Location adjacent to an interface definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "UNION", + "description": "Location adjacent to a union definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ENUM", + "description": "Location adjacent to an enum definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ENUM_VALUE", + "description": "Location adjacent to an enum value definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INPUT_OBJECT", + "description": "Location adjacent to an input object type definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INPUT_FIELD_DEFINITION", + "description": "Location adjacent to an input object field definition.", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__EnumValue", + "description": "One possible value for a given Enum. Enum values are unique values, not a placeholder for a string or numeric value. However an Enum value is returned in a JSON response as a string.", + "fields": [ + { + "name": "deprecationReason", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "isDeprecated", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__Field", + "description": "Object and Interface types are described by a list of Fields, each of which has a name, potentially a list of arguments, and a return type.", + "fields": [ + { + "name": "args", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__InputValue", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "deprecationReason", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "isDeprecated", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "type", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__InputValue", + "description": "Arguments provided to Fields or Directives and the input fields of an InputObject are represented as Input Values which describe their type and optionally a default value.", + "fields": [ + { + "name": "defaultValue", + "description": "A GraphQL-formatted string representing the default value for this input value.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "type", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__Schema", + "description": "A GraphQL Schema defines the capabilities of a GraphQL server. It exposes all available types and directives on the server, as well as the entry points for query, mutation, and subscription operations.", + "fields": [ + { + "name": "directives", + "description": "A list of all directives supported by this server.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Directive", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mutationType", + "description": "If this server supports mutation, the type that mutation operations will be rooted at.", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "queryType", + "description": "The type that query operations will be rooted at.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionType", + "description": "If this server support subscription, the type that subscription operations will be rooted at.", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "types", + "description": "A list of all types supported by this server.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__Type", + "description": "The fundamental unit of any GraphQL Schema is the type. There are many kinds of types in GraphQL as represented by the `__TypeKind` enum.\n\nDepending on the kind of a type, certain fields describe information about that type. Scalar types provide no information beyond a name and description, while Enum types provide their values. Object and Interface types provide the fields they describe. Abstract types, Union and Interface, provide the Object types possible at runtime. List and NonNull types compose other types.", + "fields": [ + { + "name": "description", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "enumValues", + "description": null, + "args": [ + { + "name": "includeDeprecated", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": "false" + } + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__EnumValue", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fields", + "description": null, + "args": [ + { + "name": "includeDeprecated", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": "false" + } + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Field", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "inputFields", + "description": null, + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__InputValue", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "interfaces", + "description": null, + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "kind", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "__TypeKind", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ofType", + "description": null, + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "possibleTypes", + "description": null, + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "__TypeKind", + "description": "An enum describing what kind of type a given `__Type` is.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "SCALAR", + "description": "Indicates this type is a scalar.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "OBJECT", + "description": "Indicates this type is an object. `fields` and `interfaces` are valid fields.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INTERFACE", + "description": "Indicates this type is an interface. `fields` and `possibleTypes` are valid fields.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "UNION", + "description": "Indicates this type is a union. `possibleTypes` is a valid field.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ENUM", + "description": "Indicates this type is an enum. `enumValues` is a valid field.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INPUT_OBJECT", + "description": "Indicates this type is an input object. `inputFields` is a valid field.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "LIST", + "description": "Indicates this type is a list. `ofType` is a valid field.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "NON_NULL", + "description": "Indicates this type is a non-null. `ofType` is a valid field.", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + } + ], + "directives": [ + { + "name": "include", + "description": "Directs the executor to include this field or fragment only when the `if` argument is true.", + "locations": [ + "FIELD", + "FRAGMENT_SPREAD", + "INLINE_FRAGMENT" + ], + "args": [ + { + "name": "if", + "description": "Included when true.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "defaultValue": null + } + ] + }, + { + "name": "skip", + "description": "Directs the executor to skip this field or fragment when the `if` argument is true.", + "locations": [ + "FIELD", + "FRAGMENT_SPREAD", + "INLINE_FRAGMENT" + ], + "args": [ + { + "name": "if", + "description": "Skipped when true.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "defaultValue": null + } + ] + }, + { + "name": "deprecated", + "description": "Marks an element of a GraphQL schema as no longer supported.", + "locations": [ + "FIELD_DEFINITION", + "ENUM_VALUE" + ], + "args": [ + { + "name": "reason", + "description": "Explains why this element was deprecated, usually also including a suggestion for how to access supported similar data. Formatted in [Markdown](https://daringfireball.net/projects/markdown/).", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": "\"No longer supported\"" + } + ] + } + ] + } + } +} \ No newline at end of file diff --git a/test/fixtures/graphql/dummy_schema.rb b/test/fixtures/graphql/dummy_schema.rb new file mode 100644 index 000000000..5f70b359a --- /dev/null +++ b/test/fixtures/graphql/dummy_schema.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true +require 'graphql' + +module DummySchema + class Product < GraphQL::Schema::Object + field :name, String, null: false + end + + class QueryRoot < GraphQL::Schema::Object + field :product, Product, null: false + end + + class Schema < GraphQL::Schema + query(QueryRoot) + end +end diff --git a/test/fixtures/graphql/unstable.json b/test/fixtures/graphql/unstable.json new file mode 100644 index 000000000..eb718fb7d --- /dev/null +++ b/test/fixtures/graphql/unstable.json @@ -0,0 +1,1083 @@ +{ + "data": { + "__schema": { + "queryType": { + "name": "QueryRoot" + }, + "mutationType": null, + "subscriptionType": null, + "types": [ + { + "kind": "SCALAR", + "name": "Boolean", + "description": "Represents `true` or `false` values.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Product", + "description": null, + "fields": [ + { + "name": "name", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "QueryRoot", + "description": null, + "fields": [ + { + "name": "product", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Product", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "String", + "description": "Represents textual data as UTF-8 character sequences. This type is most often used by GraphQL to represent free-form human-readable text.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__Directive", + "description": "A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document.\n\nIn some cases, you need to provide options to alter GraphQL's execution behavior in ways field arguments will not suffice, such as conditionally including or skipping a field. Directives provide this by describing additional information to the executor.", + "fields": [ + { + "name": "args", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__InputValue", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "locations", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "__DirectiveLocation", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "onField", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": true, + "deprecationReason": "Use `locations`." + }, + { + "name": "onFragment", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": true, + "deprecationReason": "Use `locations`." + }, + { + "name": "onOperation", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": true, + "deprecationReason": "Use `locations`." + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "__DirectiveLocation", + "description": "A Directive can be adjacent to many parts of the GraphQL language, a __DirectiveLocation describes one such possible adjacencies.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "QUERY", + "description": "Location adjacent to a query operation.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MUTATION", + "description": "Location adjacent to a mutation operation.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SUBSCRIPTION", + "description": "Location adjacent to a subscription operation.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "FIELD", + "description": "Location adjacent to a field.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "FRAGMENT_DEFINITION", + "description": "Location adjacent to a fragment definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "FRAGMENT_SPREAD", + "description": "Location adjacent to a fragment spread.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INLINE_FRAGMENT", + "description": "Location adjacent to an inline fragment.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SCHEMA", + "description": "Location adjacent to a schema definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SCALAR", + "description": "Location adjacent to a scalar definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "OBJECT", + "description": "Location adjacent to an object type definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "FIELD_DEFINITION", + "description": "Location adjacent to a field definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ARGUMENT_DEFINITION", + "description": "Location adjacent to an argument definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INTERFACE", + "description": "Location adjacent to an interface definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "UNION", + "description": "Location adjacent to a union definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ENUM", + "description": "Location adjacent to an enum definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ENUM_VALUE", + "description": "Location adjacent to an enum value definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INPUT_OBJECT", + "description": "Location adjacent to an input object type definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INPUT_FIELD_DEFINITION", + "description": "Location adjacent to an input object field definition.", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__EnumValue", + "description": "One possible value for a given Enum. Enum values are unique values, not a placeholder for a string or numeric value. However an Enum value is returned in a JSON response as a string.", + "fields": [ + { + "name": "deprecationReason", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "isDeprecated", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__Field", + "description": "Object and Interface types are described by a list of Fields, each of which has a name, potentially a list of arguments, and a return type.", + "fields": [ + { + "name": "args", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__InputValue", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "deprecationReason", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "isDeprecated", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "type", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__InputValue", + "description": "Arguments provided to Fields or Directives and the input fields of an InputObject are represented as Input Values which describe their type and optionally a default value.", + "fields": [ + { + "name": "defaultValue", + "description": "A GraphQL-formatted string representing the default value for this input value.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "type", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__Schema", + "description": "A GraphQL Schema defines the capabilities of a GraphQL server. It exposes all available types and directives on the server, as well as the entry points for query, mutation, and subscription operations.", + "fields": [ + { + "name": "directives", + "description": "A list of all directives supported by this server.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Directive", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mutationType", + "description": "If this server supports mutation, the type that mutation operations will be rooted at.", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "queryType", + "description": "The type that query operations will be rooted at.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionType", + "description": "If this server support subscription, the type that subscription operations will be rooted at.", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "types", + "description": "A list of all types supported by this server.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__Type", + "description": "The fundamental unit of any GraphQL Schema is the type. There are many kinds of types in GraphQL as represented by the `__TypeKind` enum.\n\nDepending on the kind of a type, certain fields describe information about that type. Scalar types provide no information beyond a name and description, while Enum types provide their values. Object and Interface types provide the fields they describe. Abstract types, Union and Interface, provide the Object types possible at runtime. List and NonNull types compose other types.", + "fields": [ + { + "name": "description", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "enumValues", + "description": null, + "args": [ + { + "name": "includeDeprecated", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": "false" + } + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__EnumValue", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fields", + "description": null, + "args": [ + { + "name": "includeDeprecated", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": "false" + } + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Field", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "inputFields", + "description": null, + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__InputValue", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "interfaces", + "description": null, + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "kind", + "description": null, + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "__TypeKind", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ofType", + "description": null, + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "possibleTypes", + "description": null, + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "__TypeKind", + "description": "An enum describing what kind of type a given `__Type` is.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "SCALAR", + "description": "Indicates this type is a scalar.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "OBJECT", + "description": "Indicates this type is an object. `fields` and `interfaces` are valid fields.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INTERFACE", + "description": "Indicates this type is an interface. `fields` and `possibleTypes` are valid fields.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "UNION", + "description": "Indicates this type is a union. `possibleTypes` is a valid field.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ENUM", + "description": "Indicates this type is an enum. `enumValues` is a valid field.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INPUT_OBJECT", + "description": "Indicates this type is an input object. `inputFields` is a valid field.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "LIST", + "description": "Indicates this type is a list. `ofType` is a valid field.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "NON_NULL", + "description": "Indicates this type is a non-null. `ofType` is a valid field.", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + } + ], + "directives": [ + { + "name": "include", + "description": "Directs the executor to include this field or fragment only when the `if` argument is true.", + "locations": [ + "FIELD", + "FRAGMENT_SPREAD", + "INLINE_FRAGMENT" + ], + "args": [ + { + "name": "if", + "description": "Included when true.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "defaultValue": null + } + ] + }, + { + "name": "skip", + "description": "Directs the executor to skip this field or fragment when the `if` argument is true.", + "locations": [ + "FIELD", + "FRAGMENT_SPREAD", + "INLINE_FRAGMENT" + ], + "args": [ + { + "name": "if", + "description": "Skipped when true.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "defaultValue": null + } + ] + }, + { + "name": "deprecated", + "description": "Marks an element of a GraphQL schema as no longer supported.", + "locations": [ + "FIELD_DEFINITION", + "ENUM_VALUE" + ], + "args": [ + { + "name": "reason", + "description": "Explains why this element was deprecated, usually also including a suggestion for how to access supported similar data. Formatted in [Markdown](https://daringfireball.net/projects/markdown/).", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": "\"No longer supported\"" + } + ] + } + ] + } + } +} \ No newline at end of file diff --git a/test/graphql/http_client_test.rb b/test/graphql/http_client_test.rb new file mode 100644 index 000000000..aa482a37e --- /dev/null +++ b/test/graphql/http_client_test.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true +require 'test_helper' + +module GraphQL + class HTTPClientTest < Test::Unit::TestCase + test '#headers uses the Base headers' do + ShopifyAPI::Base.headers['X-Custom'] = 'abc' + + client = ShopifyAPI::GraphQL::HTTPClient.new('2019-07') + + assert_equal 'abc', client.headers({})['X-Custom'] + + ShopifyAPI::Base.headers.delete('X-Custom') + end + + test '#uri uses the Base site and the API version' do + ShopifyAPI::Base.site = 'https://foo:bar@www.zombo.com' + api_version = ShopifyAPI::ApiVersion.new(handle: '2019-07') + + client = ShopifyAPI::GraphQL::HTTPClient.new(api_version) + expected_uri = URI('https://foo:bar@www.zombo.com/admin/api/2019-07/graphql.json') + + assert_equal expected_uri, client.uri + end + end +end diff --git a/test/graphql_test.rb b/test/graphql_test.rb new file mode 100644 index 000000000..39d05f66a --- /dev/null +++ b/test/graphql_test.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true +require 'test_helper' +require_relative 'fixtures/graphql/dummy_schema' + +class GraphQLTest < Test::Unit::TestCase + def setup + ShopifyAPI::ApiVersion.version_lookup_mode = :define_on_unknown + @fixture_location = Pathname('test/fixtures/graphql') + @site = 'https://this-is-my-test-shop.myshopify.com' + ShopifyAPI::Base.site = @site + end + + def teardown + ShopifyAPI::GraphQL.clear_clients + end + + test '#initialize_clients creates a GraphQL::Client from local schema file' do + version_fixtures('unstable') do |dir| + ShopifyAPI::GraphQL.initialize_clients + + assert ShopifyAPI::GraphQL.client('unstable') + end + end + + test '#initialize_clients handles multiple schema files' do + version_fixtures('unstable', '2019-10') do |dir| + ShopifyAPI::GraphQL.initialize_clients + + assert ShopifyAPI::GraphQL.client('unstable') + assert ShopifyAPI::GraphQL.client('2019-10') + end + end + + test '#initialize_clients ignores non JSON schema files' do + version_fixtures('unstable', '2019-10') do |dir| + FileUtils.touch(ShopifyAPI::GraphQL.schema_location.join('nope.txt')) + + ShopifyAPI::GraphQL.initialize_clients + + assert ShopifyAPI::GraphQL.client('unstable') + assert ShopifyAPI::GraphQL.client('2019-10') + end + end + + test '#initialize_clients raises if a JSON schema file is not named after a version' do + version_fixtures do |dir| + ShopifyAPI::GraphQL.schema_location = dir + FileUtils.touch(ShopifyAPI::GraphQL.schema_location.join('nope.json')) + + assert_raises ShopifyAPI::GraphQL::InvalidSchema do + ShopifyAPI::GraphQL.initialize_clients + end + end + end + + test '#client returns default schema if only one exists' do + version_fixtures('unstable') do |dir| + ShopifyAPI::Base.api_version = 'unstable' + + ShopifyAPI::GraphQL.initialize_clients + + assert_instance_of ::GraphQL::Client, ShopifyAPI::GraphQL.client + end + end + + test '#client accepts optional api_version parameter' do + version_fixtures('unstable') do |dir| + ShopifyAPI::Base.api_version = 'unstable' + + ShopifyAPI::GraphQL.initialize_clients + + assert_instance_of ::GraphQL::Client, ShopifyAPI::GraphQL.client('unstable') + end + end + + test '#client executes queries on specified API version' do + version_fixtures('unstable', '2019-10') do |dir| + ShopifyAPI::Base.api_version = 'unstable' + + ShopifyAPI::GraphQL.initialize_clients + ShopifyAPI::Base.site = 'https://this-is-my-test-shop.myshopify.com' + + client = ShopifyAPI::GraphQL.client('2019-10') + + assert_instance_of ::GraphQL::Client, client + + query = client.parse <<~GRAPHQL + { + product { + name + } + } + GRAPHQL + + path = ShopifyAPI::ApiVersion.new('2019-10').construct_graphql_path + stub_request(:post, "#{@site}#{path}").to_return(body: { product: { name: 'Shirt' } }.to_json) + + client.query(query) + end + end + + test '#client raises exception for version that does not exist' do + version_fixtures('unstable') do |dir| + ShopifyAPI::Base.api_version = '2019-10' + + ShopifyAPI::GraphQL.initialize_clients + + assert_raises ShopifyAPI::GraphQL::InvalidClient do + ShopifyAPI::GraphQL.client('2019-10') + end + end + end + + test '#client lazily initializes clients' do + version_fixtures('unstable') do |dir| + ShopifyAPI::Base.api_version = 'unstable' + + assert_raises ShopifyAPI::GraphQL::InvalidClient do + ShopifyAPI::GraphQL.client('2019-10') + end + end + end + + test '#client caches lookups' do + version_fixtures('unstable') do |dir| + ShopifyAPI::Base.api_version = 'unstable' + + client1 = ShopifyAPI::GraphQL.client + client2 = ShopifyAPI::GraphQL.client('unstable') + + assert_equal client1, client2 + end + end + + private + + def version_fixtures(*versions) + Dir.mktmpdir do |dir| + versions.each do |version| + FileUtils.cp(@fixture_location.join("#{version}.json"), dir) + end + + ShopifyAPI::GraphQL.schema_location = dir + yield(dir) + end + end +end From b87b06b851a7b7ca2d244e301fe170ece219c945 Mon Sep 17 00:00:00 2001 From: Scott Walkinshaw Date: Tue, 21 Jan 2020 17:04:09 -0500 Subject: [PATCH 2/3] Add documentation --- README.md | 15 ++- docs/graphql.md | 191 ++++++++++++++++++++++++++++++ lib/shopify_api/graphql/task.rake | 56 +++++++-- 3 files changed, 245 insertions(+), 17 deletions(-) create mode 100644 docs/graphql.md diff --git a/README.md b/README.md index 58f015604..4b5078932 100644 --- a/README.md +++ b/README.md @@ -346,15 +346,18 @@ gem install shopify_api_console ## GraphQL -This library also supports Shopify's new [GraphQL API](https://help.shopify.com/api/graphql-admin-api) -via a dependency on the [graphql-client](https://github.com/github/graphql-client) gem. +Note: the GraphQL client has improved and changed in version 9.0. See the [client documentation](docs/graphql.md) +for full usage details and a [migration guide](docs/graphql.md#migration-guide). + +This library also supports Shopify's [GraphQL Admin API](https://help.shopify.com/api/graphql-admin-api) +via integration with the [graphql-client](https://github.com/github/graphql-client) gem. The authentication process (steps 1-5 under [Getting Started](#getting-started)) -is identical. Once your session is activated, simply construct a new graphql -client and use `parse` and `query` as defined by +is identical. Once your session is activated, simply access the GraphQL client +and use `parse` and `query` as defined by [graphql-client](https://github.com/github/graphql-client#defining-queries). ```ruby -client = ShopifyAPI::GraphQL.new +client = ShopifyAPI::GraphQL.client SHOP_NAME_QUERY = client.parse <<-'GRAPHQL' { @@ -368,6 +371,8 @@ result = client.query(SHOP_NAME_QUERY) result.data.shop.name ``` +[GraphQL client documentation](docs/graphql.md) + ## Threadsafety ActiveResource is threadsafe as of version 4.1 (which works with Rails 4.x and above). diff --git a/docs/graphql.md b/docs/graphql.md new file mode 100644 index 000000000..1e250daf7 --- /dev/null +++ b/docs/graphql.md @@ -0,0 +1,191 @@ +# GraphQL client + +The `shopify_api` gem includes a full featured GraphQL client to interact with +Shopify's [GraphQL Admin API](https://help.shopify.com/en/api/graphql-admin-api). +GitHub's [graphql-client](https://github.com/github/graphql-client) is used as +the underlying client and this library integrates it with our existing +session, authentication, and API versioning features. + +## Example + +```ruby +client = ShopifyAPI::GraphQL.client + +SHOP_NAME_QUERY = client.parse <<-'GRAPHQL' + { + shop { + name + } + } +GRAPHQL + +result = client.query(SHOP_NAME_QUERY) +result.data.shop.name +``` + +* [Getting started](#getting-started) +* [Rails integration](#rails-integration) +* [API versioning](#api-versioning) +* [Initialization process](#initialization-process) +* [Migration guide](#migration-guide) + +## Getting started + +1. [Dump the schema](#dump-the-schema) +2. [Configure session/authencation](#sessions-and-authentication) +3. [Make queries](#make-queries) + +### Dump the schema +One of the main benefits of GraphQL is its [schema and type system](https://graphql.org/learn/schema/) +which enables tools like graphql-client to ensure your queries are valid in development. + +So the first step in making GraphQL queries is having a local JSON file of Shopify's Admin schema. +This gem provides a `shopify_api:graphql:dump` Rake task to make it as easy as possible: + +```bash +$ rake shopify_api:graphql:dump SHOP_URL="https://API_KEY:PASSWORD@SHOP_NAME.myshopify.com" API_VERSION=2020-01 +``` + +If successful `db/shopify_graphql_schemas/2020-01.json` will be created. + +You can either use private app authentication or an OAuth access token. Run `rake shopify_api:graphql:dump` +to see full usage details. + +If you're using shopify_api in a Rails app, the default location for schema files is `db/shopify_graphql_schemas`. +For non-Rails applications, the default is `shopify_graphql_schemas` in your project root. + +The schema path location can be changed via `ShopifyAPI::GraphQL.schema_location`: + +```ruby +ShopifyAPI::GraphQL.schema_location = 'assets/schemas' +``` + +#### Updating schemas +Each time you want to use a new API version, or update an existing one +(such as the `unstable` version), simply run the Rake task again to overwrite the file. + +### Sessions and authentication +The GraphQL client is designed to be integrated with the rest of shopify_api so +all its features such as sessions, authentication, and API versioning work the +exact same. + +If you've already been using the shopify_api gem in your application to make +REST API calls then no other configuration is necessary. + +Steps 1-5 of our main [Getting started](https://github.com/Shopify/shopify_api#getting-started) +section still apply for the GraphQL client as well. + +### Make queries +Now that you've dumped a schema file and configured an authenticated session, you can make GraphQL API requests. +graphql-client encourages all queries to be defined statically as constants: + +```ruby +SHOP_NAME_QUERY = ShopifyAPI::GraphQL.client.parse <<-'GRAPHQL' + { + shop { + name + } + } +GRAPHQL + +result = ShopifyAPI::GraphQL.client.query(SHOP_NAME_QUERY) +result.data.shop.name +``` + +But we've also enabled its `allow_dynamic_queries` option if you prefer: + +```ruby +query = ShopifyAPI::GraphQL.client.parse <<-'GRAPHQL' + { + shop { + name + } + } +GRAPHQL + +result = ShopifyAPI::GraphQL.client.query(query) +result.data.shop.name +``` + +See the [graphql-client documentation](https://github.com/github/graphql-client#defining-queries) +for more details on defining and executing queries. + +## Rails integration +`ShopifyAPI::GraphQL` integrates with Rails to automatically do the following: + +* load the `shopify_api:graphql:dump` Rake task +* set the `schema_location` to be in the `db` directory in your Rails root +* initialize clients in the Rails app initializer phase + +## API versioning +`ShopifyAPI::GraphQL` is version aware and lets you easily make queries to multiple +API versions through version specific clients if need be. + +If you have multiple clients and need to be explicit you can specify the version parameter: + +```ruby +ShopifyAPI::GraphQL.client # defaults to the client using ShopifyAPI::Base.api_version +ShopifyAPI::GraphQL.client('unstable') +``` + +## Initialization process +`ShopifyAPI::GraphQL` is a thin integration layer which initializes `GraphQL::Client`s +from local schema files. + +`ShopifyAPI::GraphQL.initialize_clients` scans `ShopifyAPI::GraphQL.schema_location` +and creates a client for each version specific schema file found. + +This happens automatically in a Rails application due to our [integration](#rails-integration). +For non-Rails applications, ensure you call `ShopifyAPI::GraphQL.initialize_clients` +during your boot process. + +The goal is to have all clients created at boot so there's no schema loading, +parsing, or client instantiation done during runtime when your app serves a request. + +## Migration guide +Prior to shopify_api v9.0 the GraphQL client implementation was limited and almost +unusable due to the client making dynamic introspection queries to Shopify's API. +This was not only very slow but also led to unbounded memory growth. + +There are two steps to migrate to the new client: +1. [Dump a local schema file](#dump-the-schema) +2. [Migrate `client` usage](#migrate-usage) + +### Migrate usage + +Previously a client was initialized with `ShopifyAPI::GraphQL.new`: +```ruby +client = ShopifyAPI::GraphQL.new + +SHOP_NAME_QUERY = client.parse <<-'GRAPHQL' + { + shop { + name + } + } +GRAPHQL + +result = client.query(SHOP_NAME_QUERY) +result.data.shop.name +``` + +Now there's no need to initialize a client so all references to +`ShopifyAPI::GraphQL.new` should be removed and instead the client is called +via `ShopifyAPI::GraphQL.client`: + +```ruby +client = ShopifyAPI::GraphQL.client + +SHOP_NAME_QUERY = client.parse <<-'GRAPHQL' + { + shop { + name + } + } +GRAPHQL + +result = client.query(SHOP_NAME_QUERY) +result.data.shop.name +``` + +See [making queries](#making-queries) for more usage details. diff --git a/lib/shopify_api/graphql/task.rake b/lib/shopify_api/graphql/task.rake index 6c465a394..5c76639b7 100644 --- a/lib/shopify_api/graphql/task.rake +++ b/lib/shopify_api/graphql/task.rake @@ -7,30 +7,62 @@ namespace :shopify_api do # 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' + desc 'Dumps a local JSON schema file of the Shopify Admin API' 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'] + usage = <<~USAGE + + Usage: rake shopify_api:graphql:dump [] + + Dumps a local JSON schema file of the Shopify Admin API. The schema is specific to an + API version and authentication is required (either OAuth or private app). + + Dump the schema file for the 2020-01 API version using private app authentication: + $ rake shopify_api:graphql:dump SHOP_URL="https://API_KEY:PASSWORD@SHOP_NAME.myshopify.com" API_VERSION=2020-01 + + Dump the schema file for the unstable API version using an OAuth access token: + $ rake shopify_api:graphql:dump SHOP_DOMAIN=SHOP_NAME.myshopify.com ACCESS_TOKEN=abc API_VERSION=unstable + + See https://github.com/Shopify/shopify_api#getting-started for more + details on getting started with authenticated API calls. + + Arguments: + ACCESS_TOKEN OAuth access token (shop specific) + API_VERSION API version handle [example: 2020-01] + SHOP_DOMAIN Shop domain (without path) [example: SHOP_NAME.myshopify.com] + SHOP_URL Shop URL for private apps [example: https://API_KEY:PASSWORD@SHOP_NAME.myshopify.com] + USAGE + access_token = ENV['ACCESS_TOKEN'] || ENV['access_token'] + api_version = ENV['API_VERSION'] || ENV['api_version'] + shop_url = ENV['SHOP_URL'] || ENV['shop_url'] + shop_domain = ENV['SHOP_DOMAIN'] || ENV['shop_domain'] + + unless access_token || api_version || shop_url || shop_domain + puts usage + exit(1) + end - unless site_url || shop_domain - puts 'Either SHOP_DOMAIN or SITE_URL is required for authentication' + unless shop_url || shop_domain + puts 'Error: either SHOP_DOMAIN or SHOP_URL is required for authentication' + puts usage 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.' + if shop_url && shop_domain + puts 'Error: SHOP_DOMAIN and SHOP_URL cannot be used together. Use one or the other for authentication.' + puts usage exit(1) end if shop_domain && !access_token - puts 'ACCESS_TOKEN required when SHOP_DOMAIN is used' + puts 'Error: ACCESS_TOKEN required when SHOP_DOMAIN is used' + puts usage exit(1) end unless api_version - puts "API_VERSION required. Example `2020-01`" + puts 'Error: API_VERSION required. Example: 2020-01' + puts usage exit(1) end @@ -40,8 +72,8 @@ namespace :shopify_api do 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 + if shop_url + ShopifyAPI::Base.site = shop_url end schema_location = ShopifyAPI::GraphQL.schema_location From de79d8c454e172de9d708c95a4a7732cd1d22486 Mon Sep 17 00:00:00 2001 From: Scott Walkinshaw Date: Thu, 23 Jan 2020 15:23:08 -0500 Subject: [PATCH 3/3] Improve rake task * Fixes Rails `environment` task prereq * Does a preflight request to ensure authentication works * Improved output --- lib/shopify_api/graphql/task.rake | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/lib/shopify_api/graphql/task.rake b/lib/shopify_api/graphql/task.rake index 5c76639b7..21e511ae1 100644 --- a/lib/shopify_api/graphql/task.rake +++ b/lib/shopify_api/graphql/task.rake @@ -3,12 +3,8 @@ 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 'Dumps a local JSON schema file of the Shopify Admin API' - task dump: prereqs do + task :dump do usage = <<~USAGE Usage: rake shopify_api:graphql:dump [] @@ -66,6 +62,8 @@ namespace :shopify_api do exit(1) end + Rake::Task['environment'].invoke if Rake::Task.task_defined?('environment') + ShopifyAPI::ApiVersion.fetch_known_versions ShopifyAPI::ApiVersion.version_lookup_mode = :raise_on_unknown @@ -76,11 +74,27 @@ namespace :shopify_api do ShopifyAPI::Base.site = shop_url end + puts "Fetching schema for #{ShopifyAPI::Base.api_version.handle} API version..." + + client = ShopifyAPI::GraphQL::HTTPClient.new(ShopifyAPI::Base.api_version) + document = GraphQL.parse('{ __schema { queryType { name } } }') + response = client.execute(document: document).to_h + + unless response['data'].present? + puts "Error: failed to query the API." + puts "Response: #{response}" + puts 'Ensure your SHOP_DOMAIN or SHOP_URL are valid and you have valid authentication credentials.' + puts usage + exit(1) + 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) - GraphQL::Client.dump_schema(client, schema_location.join("#{api_version}.json").to_s) + schema_file = schema_location.join("#{api_version}.json") + GraphQL::Client.dump_schema(client, schema_file.to_s) + + puts "Wrote file #{schema_file}" end end end