From 586c775b10308d73c7030e3bbb9238a40c8c9efa Mon Sep 17 00:00:00 2001 From: Greg MacWilliam Date: Tue, 11 Mar 2025 08:32:32 -0400 Subject: [PATCH] response transformer tests. --- README.md | 5 +- example/Gemfile | 1 + example/server.rb | 38 +- lib/shopify_custom_data_graphql/client.rb | 22 +- .../prepared_query.rb | 38 +- .../request_transformer.rb | 22 +- shopify_custom_data_graphql.gemspec | 1 + .../coalesces_value_object_selections.json | 14 + .../errors_with_list_path.json | 16 +- .../errors_with_object_path.json | 12 +- ...racts_value_object_selection_fragents.json | 21 + .../fragment_spreads_with_type_condition.json | 14 + .../inline_fragments_with_type_condition.json | 14 + .../metaobject_system_extensions.json | 21 + .../mixed_reference_returning_taco.json | 3 +- .../responses/root_query_extensions.json | 22 + ...sforms_extensions_fields_with_aliases.json | 15 + ...ransforms_extensions_reference_fields.json | 9 +- ...orms_extensions_reference_list_fields.json | 7 +- .../transforms_extensions_scalar_fields.json | 3 +- .../transforms_extensions_typename.json | 0 ...sforms_extensions_value_object_fields.json | 1 + .../transforms_fragments_on_custom_scope.json | 13 + ...sforms_metaobject_fields_with_aliases.json | 19 + ...ransforms_metaobject_reference_fields.json | 26 + ...orms_metaobject_reference_list_fields.json | 48 ++ .../transforms_metaobject_scalar_fields.json | 18 + .../transforms_metaobject_typename.json | 13 + ...sforms_metaobject_value_object_fields.json | 25 + .../transforms_nested_extension_fields.json | 21 + .../transforms_nested_metaobject_fields.json | 23 + ...nsforms_only_custom_metaobject_fields.json | 17 + .../request_transformer_test.rb | 2 +- .../response_transformer_test.rb | 587 +++++++++++++++++- test/test_helper.rb | 24 +- 35 files changed, 1019 insertions(+), 116 deletions(-) create mode 100644 test/fixtures/responses/coalesces_value_object_selections.json rename test/fixtures/{casettes => responses}/errors_with_list_path.json (63%) rename test/fixtures/{casettes => responses}/errors_with_object_path.json (69%) create mode 100644 test/fixtures/responses/extracts_value_object_selection_fragents.json create mode 100644 test/fixtures/responses/fragment_spreads_with_type_condition.json create mode 100644 test/fixtures/responses/inline_fragments_with_type_condition.json create mode 100644 test/fixtures/responses/metaobject_system_extensions.json rename test/fixtures/{casettes => responses}/mixed_reference_returning_taco.json (83%) create mode 100644 test/fixtures/responses/root_query_extensions.json create mode 100644 test/fixtures/responses/transforms_extensions_fields_with_aliases.json rename test/fixtures/{casettes => responses}/transforms_extensions_reference_fields.json (64%) rename test/fixtures/{casettes => responses}/transforms_extensions_reference_list_fields.json (71%) rename test/fixtures/{casettes => responses}/transforms_extensions_scalar_fields.json (87%) rename test/fixtures/{casettes => responses}/transforms_extensions_typename.json (100%) rename test/fixtures/{casettes => responses}/transforms_extensions_value_object_fields.json (92%) create mode 100644 test/fixtures/responses/transforms_fragments_on_custom_scope.json create mode 100644 test/fixtures/responses/transforms_metaobject_fields_with_aliases.json create mode 100644 test/fixtures/responses/transforms_metaobject_reference_fields.json create mode 100644 test/fixtures/responses/transforms_metaobject_reference_list_fields.json create mode 100644 test/fixtures/responses/transforms_metaobject_scalar_fields.json create mode 100644 test/fixtures/responses/transforms_metaobject_typename.json create mode 100644 test/fixtures/responses/transforms_metaobject_value_object_fields.json create mode 100644 test/fixtures/responses/transforms_nested_extension_fields.json create mode 100644 test/fixtures/responses/transforms_nested_metaobject_fields.json create mode 100644 test/fixtures/responses/transforms_only_custom_metaobject_fields.json diff --git a/README.md b/README.md index ff53776..2240a7a 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ An App schema promotes an app-owned custom data namespace as base fields and typ client = ShopifyCustomDataGraphQL::Client.new( # ... base_namespaces: ["$app"], - prefixed_namespaces: ["$app:*", "app--*", "custom", "other"], + prefixed_namespaces: ["$app:*", "app--*", "custom"], app_context_id: 123, ) ``` @@ -161,7 +161,6 @@ client = ShopifyCustomDataGraphQL::Client.new( Results in: * **`custom.my_field`** → `custom_myField` -* **`other.my_field`** → `other_myField` * **`app--123.my_field`**: → `myField` * **`app--123--other.my_field`** → `other_myField` * **`app--456.my_field`** → `app456_myField` @@ -169,7 +168,7 @@ Results in: * **`app--123--my_type`** → `MyTypeMetaobject` * **`app--456--my_type`** → `MyTypeApp456Metaobject` -Providing just `app_context_id` will automatically filter the schema down to just `$app` fields and types owned by the specified client. +Providing just `app_context_id` will automatically filter the schema down to just `$app` fields and types owned by the specified app id. ### Combined namespaces diff --git a/example/Gemfile b/example/Gemfile index 89390a3..dbe18f9 100644 --- a/example/Gemfile +++ b/example/Gemfile @@ -7,3 +7,4 @@ gem 'rack' gem 'rackup' gem 'graphql' gem 'activesupport' +gem 'rainbow' diff --git a/example/server.rb b/example/server.rb index 5d3dd79..2dd60df 100644 --- a/example/server.rb +++ b/example/server.rb @@ -4,6 +4,7 @@ require 'rackup' require 'json' require 'graphql' +require 'rainbow' require_relative '../lib/shopify_custom_data_graphql' class App @@ -31,9 +32,9 @@ def initialize @client.on_cache_read { |k| @mock_cache[k] } @client.on_cache_write { |k, v| @mock_cache[k] = v } - puts "Loading custom data schema..." + puts Rainbow("Loading custom data schema...").cyan.bright @client.eager_load! - puts "Done." + puts Rainbow("Done.").cyan end def call(env) @@ -41,20 +42,39 @@ def call(env) case req.path_info when /graphql/ params = JSON.parse(req.body.read) - result = @client.execute( - query: params["query"], - variables: params["variables"], - operation_name: params["operationName"], - ) + result = log_result do + @client.execute( + query: params["query"], + variables: params["variables"], + operation_name: params["operationName"], + ) + end - [200, {"content-type" => "application/json"}, [JSON.generate(result)]] + [200, {"content-type" => "application/json"}, [JSON.generate(result.to_h)]] when /refresh/ - reload_shop_schema + @client.schema(reload_custom_schema: true) [200, {"content-type" => "text/html"}, ["Shop schema refreshed!"]] else [200, {"content-type" => "text/html"}, [@graphiql]] end end + + def log_result + timestamp = Time.current + result = yield + message = [Rainbow("[request #{timestamp.to_s}]").cyan.bright] + stats = ["validate", "introspection", "transform_request", "proxy", "transform_response"].filter_map do |stat| + time = result.tracer[stat] + next unless time + + "#{Rainbow(stat).magenta}: #{(time * 100).round / 100.to_f}ms" + end + + message << stats.join(", ") + message << "\n#{result.query}" if result.tracer["transform_request"] + puts message.join(" ") + result + end end Rackup::Handler.default.run(App.new, :Port => 3000) diff --git a/lib/shopify_custom_data_graphql/client.rb b/lib/shopify_custom_data_graphql/client.rb index c848ec5..d3e04b7 100644 --- a/lib/shopify_custom_data_graphql/client.rb +++ b/lib/shopify_custom_data_graphql/client.rb @@ -103,16 +103,17 @@ def schema(reload_custom_schema: false, reload_admin_schema: false) def execute(query: nil, variables: nil, operation_name: nil) tracer = Tracer.new - result = tracer.span("execute") do - prepare_query(query, operation_name, tracer) do |admin_query| + tracer.span("execute") do + perform_query(query, operation_name, tracer) do |admin_query| @admin.fetch(admin_query, variables: variables) end end - - puts tracer.as_json - result rescue ValidationError => e - { "errors" => e.errors } + PreparedQuery::Result.new( + query: query, + tracer: tracer, + result: { "errors" => e.errors }, + ) end def on_cache_read(&block) @@ -127,7 +128,7 @@ def on_cache_write(&block) private - def prepare_query(query_str, operation_name, tracer, &block) + def perform_query(query_str, operation_name, tracer, &block) digest = @digest_class.hexdigest([query_str, operation_name, VERSION].join(" ")) if @lru && (hot_query = @lru.get(digest)) return hot_query.perform(tracer, source_query: query_str, &block) @@ -147,9 +148,12 @@ def prepare_query(query_str, operation_name, tracer, &block) end raise ValidationError.new(errors: errors.map(&:to_h)) if errors.any? - return query.result.to_h if introspection_query?(query) + if introspection_query?(query) + result = tracer.span("introspection") { query.result.to_h } + return PreparedQuery::Result.new(query: query_str, tracer: tracer, result: result) + end - prepared_query = tracer.span("request_transform") do + prepared_query = tracer.span("transform_request") do RequestTransformer.new(query).perform.to_prepared_query end json = prepared_query.to_json diff --git a/lib/shopify_custom_data_graphql/prepared_query.rb b/lib/shopify_custom_data_graphql/prepared_query.rb index 173615f..304d5aa 100644 --- a/lib/shopify_custom_data_graphql/prepared_query.rb +++ b/lib/shopify_custom_data_graphql/prepared_query.rb @@ -4,6 +4,20 @@ module ShopifyCustomDataGraphQL class PreparedQuery DEFAULT_TRACER = Tracer.new + class Result + attr_reader :query, :tracer, :result + + def initialize(query:, tracer:, result:) + @query = query + @tracer = tracer + @result = result + end + + def to_h + @result + end + end + attr_reader :query, :transforms def initialize(params) @@ -15,10 +29,6 @@ def initialize(params) end end - def has_transforms? - @transforms.any? - end - def as_json { "query" => @query, @@ -31,16 +41,18 @@ def to_json end def perform(tracer = DEFAULT_TRACER, source_query: nil) - # pass through source query when it requires no transforms - # stops queries without transformations from taking up cache space - return yield(source_query) if source_query && !has_transforms? - - response = tracer.span("proxy") do - yield(@query) - end - tracer.span("transform_response") do - ResponseTransformer.new(@transforms).perform(response) + query = source_query && @transforms.none? ? source_query : @query + raw_result = tracer.span("proxy") { yield(query) } + + result = if @transforms.any? + tracer.span("transform_response") do + ResponseTransformer.new(@transforms).perform(raw_result) + end + else + raw_result end + + Result.new(query: query, tracer: tracer, result: result) end end end diff --git a/lib/shopify_custom_data_graphql/request_transformer.rb b/lib/shopify_custom_data_graphql/request_transformer.rb index 2a98991..f37be83 100644 --- a/lib/shopify_custom_data_graphql/request_transformer.rb +++ b/lib/shopify_custom_data_graphql/request_transformer.rb @@ -42,7 +42,7 @@ def initialize(query, metafield_ns = "custom") @query = query @schema = query.schema @app_context = directive_kwargs(@schema.schema_directives, "app")&.dig(:id) - @owner_types = @schema.possible_types(@schema.get_type("HasMetafields")).to_set + @owner_types = @schema.possible_types(@query.get_type("HasMetafields")).to_set @root_ext_name = MetafieldTypeResolver.extensions_typename(@schema.query.graphql_name) @transform_map = TransformationMap.new(@app_context) @metafield_ns = metafield_ns @@ -71,13 +71,13 @@ def transform_scope(parent_type, input_selections, scope_type: NATIVE_SCOPE, sco if scope_type == NATIVE_SCOPE && node.name == "extensions" && (parent_type == @schema.query || @owner_types.include?(parent_type)) @transform_map.apply_field_transform(FieldTransform.new(NAMESPACE_TRANSFORM)) with_namespace_anchor_field(node) do - next_type = parent_type.get_field(node.name).type.unwrap + next_type = @query.get_field(parent_type, node.name).type.unwrap transform_scope(next_type, node.selections, scope_type: EXTENSIONS_SCOPE, scope_ns: node.alias || node.name) end elsif scope_type == METAOBJECT_SCOPE && node.name == "system" @transform_map.apply_field_transform(FieldTransform.new(NAMESPACE_TRANSFORM)) with_namespace_anchor_field(node) do - next_type = parent_type.get_field(node.name).type.unwrap + next_type = @query.get_field(parent_type, node.name).type.unwrap transform_scope(next_type, node.selections, scope_ns: node.alias || node.name) end elsif scope_type == EXTENSIONS_SCOPE && parent_type.graphql_name == @root_ext_name @@ -89,7 +89,7 @@ def transform_scope(parent_type, input_selections, scope_type: NATIVE_SCOPE, sco node = node.merge(alias: "#{RESERVED_PREFIX}#{scope_ns}_#{node.alias || node.name}") end if node.selections&.any? - next_type = parent_type.get_field(node.name).type.unwrap + next_type = @query.get_field(parent_type, node.name).type.unwrap node = node.merge(selections: transform_scope(next_type, node.selections)) end node @@ -97,7 +97,7 @@ def transform_scope(parent_type, input_selections, scope_type: NATIVE_SCOPE, sco end when GraphQL::Language::Nodes::InlineFragment - fragment_type = node.type.nil? ? parent_type : @schema.get_type(node.type.name) + fragment_type = node.type.nil? ? parent_type : @query.get_type(node.type.name) with_typed_condition(parent_type, fragment_type, scope_type) do if MetafieldTypeResolver.extensions_type?(fragment_type.graphql_name) transform_scope(fragment_type, node.selections, scope_type: EXTENSIONS_SCOPE, scope_ns: scope_ns) @@ -116,7 +116,7 @@ def transform_scope(parent_type, input_selections, scope_type: NATIVE_SCOPE, sco when GraphQL::Language::Nodes::FragmentSpread fragment_def = @query.fragments[node.name] - fragment_type = @schema.get_type(fragment_def.type.name) + fragment_type = @query.get_type(fragment_def.type.name) with_typed_condition(parent_type, fragment_type, scope_type) do unless @new_fragments[node.name] fragment_type_name = fragment_type.graphql_name @@ -175,7 +175,7 @@ def with_typed_condition(parent_type, fragment_type, scope_type) # connections must map transformations through possible `edges -> node` and `nodes` pathways def build_connection_selections(conn_type, conn_node) - conn_node_type = conn_type.get_field("nodes").type.unwrap + conn_node_type = @query.get_field(conn_type, "nodes").type.unwrap conn_node.selections.map do |node| @transform_map.field_breadcrumb(node) do case node.name @@ -186,7 +186,7 @@ def build_connection_selections(conn_type, conn_node) when "node" n.merge(selections: yield(conn_node_type, n.selections)) when GQL_TYPENAME - edge_type = conn_type.get_field("edges").type.unwrap + edge_type = @query.get_field(conn_type, "edges").type.unwrap @transform_map.apply_field_transform(FieldTransform.new(STATIC_TYPENAME_TRANSFORM, value: edge_type.graphql_name)) n else @@ -210,10 +210,10 @@ def build_connection_selections(conn_type, conn_node) def build_metaobject_query(parent_type, node, scope_ns: nil) return build_typename(parent_type, node, scope_type: EXTENSIONS_SCOPE, scope_ns: scope_ns) if node.name == GQL_TYPENAME - field_type = parent_type.get_field(node.name).type.unwrap + field_type = @query.get_field(parent_type, node.name).type.unwrap return node unless MetafieldTypeResolver.connection_type?(field_type.graphql_name) - node_type = field_type.get_field("nodes").type.unwrap + node_type = @query.get_field(field_type, "nodes").type.unwrap metaobject_type = directive_kwargs(node_type.directives, "metaobject")&.dig(:type) return node unless metaobject_type @@ -232,7 +232,7 @@ def build_metaobject_query(parent_type, node, scope_ns: nil) def build_metafield(parent_type, node, scope_type:, scope_ns: nil) return build_typename(parent_type, node, scope_type: scope_type, scope_ns: scope_ns) if node.name == GQL_TYPENAME - field = parent_type.get_field(node.name) + field = @query.get_field(parent_type, node.name) metafield_attrs = directive_kwargs(field.directives, "metafield") return node unless metafield_attrs diff --git a/shopify_custom_data_graphql.gemspec b/shopify_custom_data_graphql.gemspec index aacf1d1..27919ee 100644 --- a/shopify_custom_data_graphql.gemspec +++ b/shopify_custom_data_graphql.gemspec @@ -32,4 +32,5 @@ Gem::Specification.new do |spec| spec.add_development_dependency "bundler", "~> 2.0" spec.add_development_dependency "rake", "~> 12.0" spec.add_development_dependency "minitest", "~> 5.12" + spec.add_development_dependency "graphql-response_validator", "~> 0.0.2" end diff --git a/test/fixtures/responses/coalesces_value_object_selections.json b/test/fixtures/responses/coalesces_value_object_selections.json new file mode 100644 index 0000000..cbb976f --- /dev/null +++ b/test/fixtures/responses/coalesces_value_object_selections.json @@ -0,0 +1,14 @@ +{ + "data": { + "product": { + "extensions": "Product", + "___extensions_rating": { + "jsonValue": { + "value": 4.5, + "scale_max": 5, + "scale_min": 0 + } + } + } + } +} diff --git a/test/fixtures/casettes/errors_with_list_path.json b/test/fixtures/responses/errors_with_list_path.json similarity index 63% rename from test/fixtures/casettes/errors_with_list_path.json rename to test/fixtures/responses/errors_with_list_path.json index e73f939..c6247d5 100644 --- a/test/fixtures/casettes/errors_with_list_path.json +++ b/test/fixtures/responses/errors_with_list_path.json @@ -21,19 +21,5 @@ } } ], - "data": { - "products": { - "nodes": [ - { - "extensions": { - "widget": { - "system": { - "createdByStaff": null - } - } - } - } - ] - } - } + "data": null } diff --git a/test/fixtures/casettes/errors_with_object_path.json b/test/fixtures/responses/errors_with_object_path.json similarity index 69% rename from test/fixtures/casettes/errors_with_object_path.json rename to test/fixtures/responses/errors_with_object_path.json index bf1189c..04821c0 100644 --- a/test/fixtures/casettes/errors_with_object_path.json +++ b/test/fixtures/responses/errors_with_object_path.json @@ -19,15 +19,5 @@ } } ], - "data": { - "product": { - "extensions": { - "widget": { - "system": { - "createdByStaff": null - } - } - } - } - } + "data": null } diff --git a/test/fixtures/responses/extracts_value_object_selection_fragents.json b/test/fixtures/responses/extracts_value_object_selection_fragents.json new file mode 100644 index 0000000..a329ed2 --- /dev/null +++ b/test/fixtures/responses/extracts_value_object_selection_fragents.json @@ -0,0 +1,21 @@ +{ + "data": { + "product": { + "extensions": "Product", + "___extensions_rating1": { + "jsonValue": { + "value": 4.5, + "scale_max": 5, + "scale_min": 0 + } + }, + "___extensions_rating2": { + "jsonValue": { + "value": 4.5, + "scale_max": 5, + "scale_min": 0 + } + } + } + } +} diff --git a/test/fixtures/responses/fragment_spreads_with_type_condition.json b/test/fixtures/responses/fragment_spreads_with_type_condition.json new file mode 100644 index 0000000..773b2ea --- /dev/null +++ b/test/fixtures/responses/fragment_spreads_with_type_condition.json @@ -0,0 +1,14 @@ +{ + "data": { + "node": { + "id": "gid://shopify/Product/1", + "title": "Ethereal Dreams T-Shirt", + "productExt": "Product", + "___productExt_boolean": { + "jsonValue": true + }, + "___typehint": "Product", + "__typename__": "Product" + } + } +} diff --git a/test/fixtures/responses/inline_fragments_with_type_condition.json b/test/fixtures/responses/inline_fragments_with_type_condition.json new file mode 100644 index 0000000..773b2ea --- /dev/null +++ b/test/fixtures/responses/inline_fragments_with_type_condition.json @@ -0,0 +1,14 @@ +{ + "data": { + "node": { + "id": "gid://shopify/Product/1", + "title": "Ethereal Dreams T-Shirt", + "productExt": "Product", + "___productExt_boolean": { + "jsonValue": true + }, + "___typehint": "Product", + "__typename__": "Product" + } + } +} diff --git a/test/fixtures/responses/metaobject_system_extensions.json b/test/fixtures/responses/metaobject_system_extensions.json new file mode 100644 index 0000000..1c7906d --- /dev/null +++ b/test/fixtures/responses/metaobject_system_extensions.json @@ -0,0 +1,21 @@ +{ + "data": { + "extensions": "QueryRoot", + "___extensions_widgetMetaobjects": { + "nodes": [ + { + "id": "gid://shopify/Metaobject/1", + "handle": "gourmet-gadgetry", + "system": "Metaobject", + "___system_createdByStaff": { + "id": "gid://shopify/User/1" + }, + "___system_updatedAt": "2025-03-20T02:00:00Z", + "boolean": { + "jsonValue": true + } + } + ] + } + } +} diff --git a/test/fixtures/casettes/mixed_reference_returning_taco.json b/test/fixtures/responses/mixed_reference_returning_taco.json similarity index 83% rename from test/fixtures/casettes/mixed_reference_returning_taco.json rename to test/fixtures/responses/mixed_reference_returning_taco.json index e24c845..688d46d 100644 --- a/test/fixtures/casettes/mixed_reference_returning_taco.json +++ b/test/fixtures/responses/mixed_reference_returning_taco.json @@ -12,7 +12,8 @@ "jsonValue": 75 }, "__typename": "taco", - "___typehint": "taco" + "___typehint": "taco", + "__typename__": "Metaobject" } } } diff --git a/test/fixtures/responses/root_query_extensions.json b/test/fixtures/responses/root_query_extensions.json new file mode 100644 index 0000000..05100df --- /dev/null +++ b/test/fixtures/responses/root_query_extensions.json @@ -0,0 +1,22 @@ +{ + "data": { + "extensions": "QueryRoot", + "___extensions_widgetMetaobjects": { + "nodes": [ + { + "id": "gid://shopify/Metaobject/1", + "boolean":{ + "jsonValue": true + }, + "rating": { + "jsonValue": { + "scale_min": "1.0", + "scale_max": "5.0", + "value": "5.0" + } + } + } + ] + } + } +} diff --git a/test/fixtures/responses/transforms_extensions_fields_with_aliases.json b/test/fixtures/responses/transforms_extensions_fields_with_aliases.json new file mode 100644 index 0000000..70b85be --- /dev/null +++ b/test/fixtures/responses/transforms_extensions_fields_with_aliases.json @@ -0,0 +1,15 @@ +{ + "data": { + "product": { + "extensions1": "Product", + "___extensions1_myBoolean": { + "jsonValue": true + }, + "___extensions1_myTypename": "Product", + "extensions2": "Product", + "___extensions2_myColor": { + "jsonValue": "#0000FF" + } + } + } +} diff --git a/test/fixtures/casettes/transforms_extensions_reference_fields.json b/test/fixtures/responses/transforms_extensions_reference_fields.json similarity index 64% rename from test/fixtures/casettes/transforms_extensions_reference_fields.json rename to test/fixtures/responses/transforms_extensions_reference_fields.json index e52556f..e1cc683 100644 --- a/test/fixtures/casettes/transforms_extensions_reference_fields.json +++ b/test/fixtures/responses/transforms_extensions_reference_fields.json @@ -2,18 +2,21 @@ "data": { "product": { "id": "gid://shopify/Product/6885875646486", + "extensions": "Product", "___extensions_fileReference": { "reference": { "id": "gid://shopify/MediaImage/20354823356438", - "alt": "" + "alt": "Echoes of Twilight Silence", + "__typename__": "MediaImage" } }, "___extensions_productReference": { "reference": { "id": "gid://shopify/Product/6561850556438", - "title": "Aquanauts Crystal Explorer Sub" + "title": "Aquanauts Crystal Explorer Sub", + "__typename__": "Product" } } } } -} \ No newline at end of file +} diff --git a/test/fixtures/casettes/transforms_extensions_reference_list_fields.json b/test/fixtures/responses/transforms_extensions_reference_list_fields.json similarity index 71% rename from test/fixtures/casettes/transforms_extensions_reference_list_fields.json rename to test/fixtures/responses/transforms_extensions_reference_list_fields.json index afd7600..08d589a 100644 --- a/test/fixtures/casettes/transforms_extensions_reference_list_fields.json +++ b/test/fixtures/responses/transforms_extensions_reference_list_fields.json @@ -2,12 +2,14 @@ "data": { "product": { "id": "gid://shopify/Product/6885875646486", + "extensions": "Product", "___extensions_fileReferenceList": { "references": { "nodes": [ { "id": "gid://shopify/MediaImage/20354823356438", - "alt": "A scenic landscape" + "alt": "A scenic landscape", + "__typename__": "MediaImage" } ] } @@ -18,7 +20,8 @@ { "node": { "id": "gid://shopify/Product/6561850556438", - "title": "Aquanauts Crystal Explorer Sub" + "title": "Aquanauts Crystal Explorer Sub", + "__typename__": "Product" } } ] diff --git a/test/fixtures/casettes/transforms_extensions_scalar_fields.json b/test/fixtures/responses/transforms_extensions_scalar_fields.json similarity index 87% rename from test/fixtures/casettes/transforms_extensions_scalar_fields.json rename to test/fixtures/responses/transforms_extensions_scalar_fields.json index 43e6323..8decff8 100644 --- a/test/fixtures/casettes/transforms_extensions_scalar_fields.json +++ b/test/fixtures/responses/transforms_extensions_scalar_fields.json @@ -2,6 +2,7 @@ "data": { "product": { "id": "gid://shopify/Product/6885875646486", + "extensions": "Product", "___extensions_boolean": { "jsonValue": true }, @@ -10,4 +11,4 @@ } } } -} \ No newline at end of file +} diff --git a/test/fixtures/casettes/transforms_extensions_typename.json b/test/fixtures/responses/transforms_extensions_typename.json similarity index 100% rename from test/fixtures/casettes/transforms_extensions_typename.json rename to test/fixtures/responses/transforms_extensions_typename.json diff --git a/test/fixtures/casettes/transforms_extensions_value_object_fields.json b/test/fixtures/responses/transforms_extensions_value_object_fields.json similarity index 92% rename from test/fixtures/casettes/transforms_extensions_value_object_fields.json rename to test/fixtures/responses/transforms_extensions_value_object_fields.json index 2c90381..9a7986e 100644 --- a/test/fixtures/casettes/transforms_extensions_value_object_fields.json +++ b/test/fixtures/responses/transforms_extensions_value_object_fields.json @@ -2,6 +2,7 @@ "data": { "product": { "id": "gid://shopify/Product/6885875646486", + "extensions": "Product", "___extensions_dimension": { "jsonValue": { "value": 24.0, diff --git a/test/fixtures/responses/transforms_fragments_on_custom_scope.json b/test/fixtures/responses/transforms_fragments_on_custom_scope.json new file mode 100644 index 0000000..d6625fb --- /dev/null +++ b/test/fixtures/responses/transforms_fragments_on_custom_scope.json @@ -0,0 +1,13 @@ +{ + "data": { + "product": { + "extensions": "Product", + "___extensions_boolean": { + "jsonValue": true + }, + "___extensions_color": { + "jsonValue": "#F39C12" + } + } + } +} diff --git a/test/fixtures/responses/transforms_metaobject_fields_with_aliases.json b/test/fixtures/responses/transforms_metaobject_fields_with_aliases.json new file mode 100644 index 0000000..b5778d0 --- /dev/null +++ b/test/fixtures/responses/transforms_metaobject_fields_with_aliases.json @@ -0,0 +1,19 @@ +{ + "data": { + "product": { + "extensions": "Product", + "___extensions_widget": { + "reference": { + "myBoolean": { + "jsonValue": true + }, + "myColor": { + "jsonValue": "#7A3B6C" + }, + "myTypename": "widget", + "__typename__": "Metaobject" + } + } + } + } +} diff --git a/test/fixtures/responses/transforms_metaobject_reference_fields.json b/test/fixtures/responses/transforms_metaobject_reference_fields.json new file mode 100644 index 0000000..39ac369 --- /dev/null +++ b/test/fixtures/responses/transforms_metaobject_reference_fields.json @@ -0,0 +1,26 @@ +{ + "data": { + "product": { + "extensions": "Product", + "___extensions_widget": { + "reference": { + "fileReference": { + "reference": { + "id": "gid://shopify/File/1", + "alt": "A Thousand Dreams Encapsulated", + "__typename__": "MediaImage" + } + }, + "productReference": { + "reference": { + "id": "gid://shopify/Product/2", + "title": "Whispering Willows Light", + "__typename__": "Product" + } + }, + "__typename__": "Metaobject" + } + } + } + } +} diff --git a/test/fixtures/responses/transforms_metaobject_reference_list_fields.json b/test/fixtures/responses/transforms_metaobject_reference_list_fields.json new file mode 100644 index 0000000..0f1e796 --- /dev/null +++ b/test/fixtures/responses/transforms_metaobject_reference_list_fields.json @@ -0,0 +1,48 @@ +{ + "data": { + "product": { + "extensions": "Product", + "___extensions_widget": { + "reference": { + "fileReferenceList": { + "references": { + "nodes": [ + { + "id": "gid://shopify/File/101", + "alt": "Whispers of the Moonlit Sea", + "__typename__": "MediaImage" + }, + { + "id": "gid://shopify/File/102", + "alt": "Ethereal Dance of the Aurora", + "__typename__": "MediaImage" + } + ] + } + }, + "productReferenceList": { + "references": { + "edges": [ + { + "node": { + "id": "gid://shopify/Product/201", + "title": "Echoes of Twilight Silence", + "__typename__": "Product" + } + }, + { + "node": { + "id": "gid://shopify/Product/202", + "title": "Serenade of the Night's End", + "__typename__": "Product" + } + } + ] + } + }, + "__typename__": "Metaobject" + } + } + } + } +} diff --git a/test/fixtures/responses/transforms_metaobject_scalar_fields.json b/test/fixtures/responses/transforms_metaobject_scalar_fields.json new file mode 100644 index 0000000..c4e0bf9 --- /dev/null +++ b/test/fixtures/responses/transforms_metaobject_scalar_fields.json @@ -0,0 +1,18 @@ +{ + "data": { + "product": { + "extensions": "Product", + "___extensions_widget": { + "reference": { + "boolean": { + "jsonValue": true + }, + "color": { + "jsonValue": "#0000FF" + }, + "__typename__": "Metaobject" + } + } + } + } +} diff --git a/test/fixtures/responses/transforms_metaobject_typename.json b/test/fixtures/responses/transforms_metaobject_typename.json new file mode 100644 index 0000000..b3e4653 --- /dev/null +++ b/test/fixtures/responses/transforms_metaobject_typename.json @@ -0,0 +1,13 @@ +{ + "data": { + "product": { + "extensions": "Product", + "___extensions_widget": { + "reference": { + "__typename": "widget", + "__typename__": "Metaobject" + } + } + } + } +} diff --git a/test/fixtures/responses/transforms_metaobject_value_object_fields.json b/test/fixtures/responses/transforms_metaobject_value_object_fields.json new file mode 100644 index 0000000..3320aa5 --- /dev/null +++ b/test/fixtures/responses/transforms_metaobject_value_object_fields.json @@ -0,0 +1,25 @@ +{ + "data": { + "product": { + "extensions": "Product", + "___extensions_widget": { + "reference": { + "dimension": { + "jsonValue": { + "value": 23.0, + "unit": "INCHES" + } + }, + "rating": { + "jsonValue": { + "scale_min": "1.0", + "scale_max": "5.0", + "value": "5.0" + } + }, + "__typename__": "Metaobject" + } + } + } + } +} diff --git a/test/fixtures/responses/transforms_nested_extension_fields.json b/test/fixtures/responses/transforms_nested_extension_fields.json new file mode 100644 index 0000000..558f7a3 --- /dev/null +++ b/test/fixtures/responses/transforms_nested_extension_fields.json @@ -0,0 +1,21 @@ +{ + "data": { + "product": { + "title": "Neptune Discovery Base", + "extensions": "Product", + "___extensions_productReference": { + "reference": { + "title": "Crystal Explorer Sub", + "extensions": "Product", + "___extensions_boolean": { + "jsonValue": true + }, + "___extensions_color": { + "jsonValue": "#0000FF" + }, + "__typename__": "Product" + } + } + } + } +} diff --git a/test/fixtures/responses/transforms_nested_metaobject_fields.json b/test/fixtures/responses/transforms_nested_metaobject_fields.json new file mode 100644 index 0000000..ffde270 --- /dev/null +++ b/test/fixtures/responses/transforms_nested_metaobject_fields.json @@ -0,0 +1,23 @@ +{ + "data": { + "product": { + "extensions": "Product", + "___extensions_widget": { + "reference": { + "widget": { + "reference": { + "boolean": { + "jsonValue": true + }, + "color": { + "jsonValue": "#4A90E2" + }, + "__typename__": "Metaobject" + } + }, + "__typename__": "Metaobject" + } + } + } + } +} diff --git a/test/fixtures/responses/transforms_only_custom_metaobject_fields.json b/test/fixtures/responses/transforms_only_custom_metaobject_fields.json new file mode 100644 index 0000000..0b52252 --- /dev/null +++ b/test/fixtures/responses/transforms_only_custom_metaobject_fields.json @@ -0,0 +1,17 @@ +{ + "data": { + "product": { + "extensions": "Product", + "___extensions_widget": { + "reference": { + "id": "gid://shopify/Metaobject/1", + "handle": "celestial-harmony", + "boolean": { + "jsonValue": false + }, + "__typename__": "Metaobject" + } + } + } + } +} diff --git a/test/shopify_custom_data_graphql/request_transformer_test.rb b/test/shopify_custom_data_graphql/request_transformer_test.rb index 163025f..6576149 100644 --- a/test/shopify_custom_data_graphql/request_transformer_test.rb +++ b/test/shopify_custom_data_graphql/request_transformer_test.rb @@ -324,7 +324,7 @@ def test_transforms_metaobject_scalar_fields expected_query = %|query { product(id: "1") { - extensions: __typename + extensions: __typename ___extensions_widget: metafield(key: "custom.widget") { reference { ... on Metaobject { diff --git a/test/shopify_custom_data_graphql/response_transformer_test.rb b/test/shopify_custom_data_graphql/response_transformer_test.rb index 839fa69..e63cac2 100644 --- a/test/shopify_custom_data_graphql/response_transformer_test.rb +++ b/test/shopify_custom_data_graphql/response_transformer_test.rb @@ -26,7 +26,7 @@ def test_transforms_extensions_scalar_fields }, } - assert_equal expected, result.dig("data") + assert_equal expected, result.to_h.dig("data") end def test_transforms_extensions_value_object_fields @@ -56,7 +56,7 @@ def test_transforms_extensions_value_object_fields }, } - assert_equal expected, result.dig("data") + assert_equal expected, result.to_h.dig("data") end def test_transforms_extensions_reference_fields @@ -76,7 +76,7 @@ def test_transforms_extensions_reference_fields "extensions" => { "fileReference" => { "id" => "gid://shopify/MediaImage/20354823356438", - "alt" => "", + "alt" => "Echoes of Twilight Silence", }, "productReference" => { "id" => "gid://shopify/Product/6561850556438", @@ -86,7 +86,7 @@ def test_transforms_extensions_reference_fields }, } - assert_equal expected, result.dig("data") + assert_equal expected, result.to_h.dig("data") end @@ -127,7 +127,7 @@ def test_transforms_extensions_reference_list_fields }, } - assert_equal expected, result.dig("data") + assert_equal expected, result.to_h.dig("data") end def test_transforms_extensions_typename @@ -145,7 +145,479 @@ def test_transforms_extensions_typename }, } - assert_equal expected, result.dig("data") + assert_equal expected, result.to_h.dig("data") + end + + def test_transforms_nested_extension_fields + result = fetch("transforms_nested_extension_fields", %|query { + product(id: "#{PRODUCT_ID}") { + title + extensions { + productReference { + title + extensions { + boolean + color + } + } + } + } + }|) + + expected = { + "product" => { + "title" => "Neptune Discovery Base", + "extensions" => { + "productReference" => { + "title" => "Crystal Explorer Sub", + "extensions" => { + "boolean" => true, + "color" => "#0000FF", + } + } + } + } + } + + assert_equal expected, result.to_h.dig("data") + end + + def test_transforms_extensions_fields_with_aliases + result = fetch("transforms_extensions_fields_with_aliases", %|query { + product(id: "#{PRODUCT_ID}") { + extensions1: extensions { + myBoolean: boolean + myTypename: __typename + } + extensions2: extensions { + myColor: color + } + } + }|) + + expected = { + "product" => { + "extensions1" => { + "myBoolean" => true, + "myTypename" => "ProductExtensions", + }, + "extensions2" => { + "myColor" => "#0000FF", + }, + }, + } + + assert_equal expected, result.to_h.dig("data") + end + + def test_transforms_metaobject_scalar_fields + result = fetch("transforms_metaobject_scalar_fields", %|query { + product(id: "#{PRODUCT_ID}") { + extensions { + widget { + boolean + color + } + } + } + }|) + + expected = { + "product" => { + "extensions" => { + "widget" => { + "boolean" => true, + "color" => "#0000FF", + }, + }, + }, + } + + assert_equal expected, result.to_h.dig("data") + end + + def test_transforms_metaobject_value_object_fields + result = fetch("transforms_metaobject_value_object_fields", %|query { + product(id: "1") { + extensions { + widget { + dimension { unit value } + rating { maximum: max value } + } + } + } + }|) + + expected = { + "product" => { + "extensions" => { + "widget" => { + "dimension" => { + "unit" => "INCHES", + "value" => 23.0 + }, + "rating" => { + "maximum" => 5.0, + "value" => 5.0 + } + } + } + } + } + + assert_equal expected, result.to_h.dig("data") + end + + def test_transforms_metaobject_reference_fields + result = fetch("transforms_metaobject_reference_fields", %|query { + product(id: "1") { + extensions { + widget { + fileReference { id alt } + productReference { id title } + } + } + } + }|) + + expected = { + "product" => { + "extensions" => { + "widget" => { + "fileReference" => { + "id" => "gid://shopify/File/1", + "alt" => "A Thousand Dreams Encapsulated", + }, + "productReference" => { + "id" => "gid://shopify/Product/2", + "title" => "Whispering Willows Light", + }, + }, + }, + }, + } + + assert_equal expected, result.to_h.dig("data") + end + + def test_transforms_metaobject_reference_list_fields + result = fetch("transforms_metaobject_reference_list_fields", %|query { + product(id: "1") { + extensions { + widget { + fileReferenceList(first: 10, after: "r2d2") { + nodes { id alt } + } + productReferenceList(last: 10, before: "c3p0") { + edges { node { id title } } + } + } + } + } + }|) + + expected = { + "product" => { + "extensions" => { + "widget" => { + "fileReferenceList" => { + "nodes" => [ + { + "id" => "gid://shopify/File/101", + "alt" => "Whispers of the Moonlit Sea", + }, + { + "id" => "gid://shopify/File/102", + "alt" => "Ethereal Dance of the Aurora", + } + ] + }, + "productReferenceList" => { + "edges" => [ + { + "node" => { + "id" => "gid://shopify/Product/201", + "title" => "Echoes of Twilight Silence", + } + }, + { + "node" => { + "id" => "gid://shopify/Product/202", + "title" => "Serenade of the Night's End", + } + } + ] + } + } + } + } + } + + assert_equal expected, result.to_h.dig("data") + end + + def test_transforms_metaobject_typename + result = fetch("transforms_metaobject_typename", %|query { + product(id: "1") { + extensions { + widget { __typename } + } + } + }|) + + expected = { + "product" => { + "extensions" => { + "widget" => { + "__typename" => "WidgetMetaobject", + }, + }, + }, + } + + assert_equal expected, result.to_h.dig("data") + end + + def test_transforms_nested_metaobject_fields + result = fetch("transforms_nested_metaobject_fields", %|query { + product(id: "1") { + extensions { + widget { + widget { + boolean + color + } + } + } + } + }|) + + expected = { + "product" => { + "extensions" => { + "widget" => { + "widget" => { + "boolean" => true, + "color" => "#4A90E2", + }, + }, + }, + }, + } + + assert_equal expected, result.to_h.dig("data") + end + + def test_transforms_metaobject_fields_with_aliases + result = fetch("transforms_metaobject_fields_with_aliases", %|query { + product(id: "1") { + extensions { + widget { + myBoolean: boolean + myColor: color + myTypename: __typename + } + } + } + }|) + + expected = { + "product" => { + "extensions" => { + "widget" => { + "myBoolean" => true, + "myColor" => "#7A3B6C", + "myTypename" => "WidgetMetaobject", + }, + }, + }, + } + + assert_equal expected, result.to_h.dig("data") + end + + def test_transforms_only_custom_metaobject_fields + result = fetch("transforms_only_custom_metaobject_fields", %|query { + product(id: "1") { + extensions { + widget { + id + handle + boolean + } + } + } + }|) + + expected = { + "product" => { + "extensions" => { + "widget" => { + "id" => "gid://shopify/Metaobject/1", + "handle" => "celestial-harmony", + "boolean" => false, + }, + }, + }, + } + + assert_equal expected, result.to_h.dig("data") + end + + def test_coalesces_value_object_selections + result = fetch("coalesces_value_object_selections", %|query { + product(id: "1") { + extensions { + rating { + max + } + rating { + min + value + } + } + } + }|) + + expected = { + "product" => { + "extensions" => { + "rating" => { + "max" => 5, + "min" => 0, + "value" => 4.5, + }, + }, + }, + } + + assert_equal expected, result.to_h.dig("data") + end + + def test_extracts_value_object_selection_fragents + result = fetch("extracts_value_object_selection_fragents", %|query { + product(id: "1") { + extensions { + rating1: rating { + ... on RatingMetatype { max } + ... RatingAttrs + ... { value } + } + rating2: rating { + ... { + max + ... on RatingMetatype { + ... RatingAttrs + value + } + } + } + } + } + } + fragment RatingAttrs on RatingMetatype { min } + |) + + expected = { + "product" => { + "extensions" => { + "rating1" => { + "max" => 5, + "min" => 0, + "value" => 4.5, + }, + "rating2" => { + "max" => 5, + "min" => 0, + "value" => 4.5, + }, + }, + }, + } + + assert_equal expected, result.to_h.dig("data") + end + + def test_transforms_fragments_on_custom_scope + result = fetch("transforms_fragments_on_custom_scope", %|query { + product(id: "1") { + extensions { + ... on ProductExtensions { boolean } + ...ProductExtensionsAttrs + } + } + } + fragment ProductExtensionsAttrs on ProductExtensions { color } + |) + + expected = { + "product" => { + "extensions" => { + "boolean" => true, + "color" => "#F39C12", + }, + }, + } + + assert_equal expected, result.to_h.dig("data") + end + + def test_transforms_inline_fragments_with_type_condition + result = fetch("inline_fragments_with_type_condition", %|query { + node(id: "1") { + ... { id } + ... on Product { + title + productExt: extensions { boolean } + } + ... on ProductVariant { + title + variantExt: extensions { test } + } + } + }|) + + expected = { + "node" => { + "id" => "gid://shopify/Product/1", + "title" => "Ethereal Dreams T-Shirt", + "productExt" => { + "boolean" => true, + }, + }, + } + + assert_equal expected, result.to_h.dig("data") + end + + def test_transforms_fragment_spreads_with_type_condition + result = fetch("fragment_spreads_with_type_condition", %|query { + node(id: "1") { + id + ...ProductAttrs + ...VariantAttrs + } + } + fragment ProductAttrs on Product { + title + productExt: extensions { boolean } + } + fragment VariantAttrs on ProductVariant { + title + variantExt: extensions { test } + }|) + + expected = { + "node" => { + "id" => "gid://shopify/Product/1", + "title" => "Ethereal Dreams T-Shirt", + "productExt" => { + "boolean" => true, + }, + }, + } + + assert_equal expected, result.to_h.dig("data") end def test_transforms_mixed_reference_with_matching_type_selection @@ -173,7 +645,7 @@ def test_transforms_mixed_reference_with_matching_type_selection }, } - assert_equal expected, result.dig("data") + assert_equal expected, result.to_h.dig("data") end def test_transforms_mixed_reference_without_matching_type_selection @@ -198,7 +670,79 @@ def test_transforms_mixed_reference_without_matching_type_selection }, } - assert_equal expected, result.dig("data") + assert_equal expected, result.to_h.dig("data") + end + + def test_transforms_root_query_extensions + result = fetch("root_query_extensions", %|query { + extensions { + widgetMetaobjects(first: 10) { + nodes { + id + boolean + rating { + max + value + } + } + } + } + }|) + + expected = { + "extensions" => { + "widgetMetaobjects" => { + "nodes" => [{ + "id" => "gid://shopify/Metaobject/1", + "boolean" => true, + "rating" => { + "max" => 5.0, + "value" => 5.0, + }, + }], + }, + }, + } + + assert_equal expected, result.to_h.dig("data") + end + + def test_transforms_metaobject_system_extensions + result = fetch("metaobject_system_extensions", %|query { + extensions { + widgetMetaobjects(first: 10) { + nodes { + id + handle + system { + createdByStaff { id } + updatedAt + } + boolean + } + } + } + }|) + + expected = { + "extensions" => { + "widgetMetaobjects" => { + "nodes" => [{ + "id" => "gid://shopify/Metaobject/1", + "handle" => "gourmet-gadgetry", + "system" => { + "createdByStaff" => { + "id" => "gid://shopify/User/1", + }, + "updatedAt" => "2025-03-20T02:00:00Z", + }, + "boolean" => true, + }], + }, + }, + } + + assert_equal expected, result.to_h.dig("data") end def test_transforms_errors_with_object_paths @@ -212,7 +756,7 @@ def test_transforms_errors_with_object_paths } } } - }|) + }|, expect_valid_response: false) expected_errors = [{ "message" => "Access denied for createdByStaff field.", @@ -220,7 +764,7 @@ def test_transforms_errors_with_object_paths "extensions" => { "code" => "ACCESS_DENIED" }, }] - assert_equal expected_errors, result.dig("errors") + assert_equal expected_errors, result.to_h.dig("errors") end def test_transforms_errors_with_list_paths @@ -236,7 +780,7 @@ def test_transforms_errors_with_list_paths } } } - }|) + }|, expect_valid_response: false) expected_errors = [{ "message" => "Access denied for createdByStaff field.", @@ -244,12 +788,16 @@ def test_transforms_errors_with_list_paths "extensions" => { "code" => "ACCESS_DENIED" }, }] - assert_equal expected_errors, result.dig("errors") + assert_equal expected_errors, result.to_h.dig("errors") end private - def fetch(fixture, document, variables: {}, operation_name: nil, schema: nil) + SCALAR_VALIDATORS = { + "JSON" => -> (value) { true } + }.freeze + + def fetch(fixture, document, variables: {}, operation_name: nil, schema: nil, expect_valid_response: true) query = GraphQL::Query.new( schema || shop_schema, query: document, @@ -261,7 +809,18 @@ def fetch(fixture, document, variables: {}, operation_name: nil, schema: nil) refute errors.any?, "Invalid custom data query: #{errors.first.message}" if errors.any? shop_query = ShopifyCustomDataGraphQL::RequestTransformer.new(query).perform.to_prepared_query shop_query.perform do |query_string| - fetch_response(fixture, query_string) + file_path = "#{__dir__}/../fixtures/responses/#{fixture}.json" + response = JSON.parse(File.read(file_path)) + + if expect_valid_response + # validate that the cached fixture matches the request shape + admin_query = GraphQL::Query.new(base_schema, query: query_string) + fixture = GraphQL::ResponseValidator.new(admin_query, response, scalar_validators: SCALAR_VALIDATORS) + assert fixture.valid?, "#{fixture.errors.map(&:message).join("\n")} in:\n#{query_string}" + fixture.prune!.to_h + else + response + end end end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 9594461..b51a919 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -7,8 +7,7 @@ require "minitest/pride" require "minitest/autorun" -require "net/http" -require "uri" +require "graphql/response_validator" require "json" def load_base_admin_schema @@ -43,7 +42,6 @@ def load_shop_fixtures_schema(app_id: nil) $base_schema = nil $app_schema = nil $shop_schema = nil -$shop_api_client = nil $metafield_values = nil def base_schema @@ -61,23 +59,3 @@ def shop_schema def metafield_values $metafield_values ||= JSON.parse(File.read("#{__dir__}/fixtures/metafield_values.json")) end - -def shop_api_client - $shop_api_client ||= begin - secrets = JSON.parse(File.read("#{__dir__}/../secrets.json")) - ShopifyCustomDataGraphQL::AdminApiClient.new( - shop_url: secrets["shop_url"], - access_token: secrets["access_token"], - ) - end -end - -def fetch_response(casette_name, query, version: "2025-01", variables: nil) - file_path = "#{__dir__}/fixtures/casettes/#{casette_name}.json" - JSON.parse(File.read(file_path)) -rescue Errno::ENOENT - data = shop_api_client.fetch(query, variables: variables) - data.delete("extensions") - File.write(file_path, JSON.pretty_generate(data)) - data -end