From d4c473666a385f269652e9267fc7a543abc9daed Mon Sep 17 00:00:00 2001
From: Daniel Flynn <djflynn15@gmail.com>
Date: Tue, 16 Mar 2021 12:32:39 -0400
Subject: [PATCH] feat: response_data (#8)

* wip: start response_data

* test: add compact layers

- Add superdiff for better diff

* update: specs for new proposals

* working on new version of response_data to support digging through hash and array for values

* core of digging through hash and array are working. still need to get digging specific array index, and shaping from hash keys

* digging through an array by index works now

* digging by array index and field now work at a basic level

* failing spec that shows it can't yet do full nesting of field, hash, array, etc

* begin extracting dig_dug to encapsulate the response digging features

* first passing spec of first level dig with a hash

* core specs to outline the feature set of dig_dug

* dig through array to nested fields

* dig through an array to return an item by index from the array

* handling multiple hash key value sets

* additional spec to show indexed item from array that exists within item that came from an array

* corrections to hand data as an array or hash

* corrected the final spec to show digging through an array, an array by index, then grabbing the name of the items in the final array

* using DigDug in response_data

* docs for response_data

* adding an intro section for syntax, to the response_data docs

* bump version to 0.4.1 to prep for release

* adjustments to the docs

* a missing space

* log: add warning of soon to come deprecation

Co-authored-by: River Lynn Bailey <riverlynnbailey@gmail.com>
---
 Gemfile.lock                                  |  13 +-
 README.md                                     |   2 +-
 docs/operation.md                             |  37 +----
 docs/response.md                              |   2 +-
 docs/response_data.md                         | 146 ++++++++++++++++++
 lib/rspec/graphql_response.rb                 |   1 +
 lib/rspec/graphql_response/dig_dug/dig_dug.rb |  83 ++++++++++
 lib/rspec/graphql_response/helpers.rb         |   5 +-
 .../graphql_response/helpers/operation.rb     |   1 +
 .../graphql_response/helpers/response_data.rb |  13 ++
 lib/rspec/graphql_response/version.rb         |   2 +-
 rspec-graphql_response.gemspec                |   1 +
 spec/graphql/queries/characters.rb            |  16 +-
 spec/graphql/types/response/character.rb      |   1 +
 spec/graphql_response/dig_dug/dig_dug_spec.rb | 144 +++++++++++++++++
 .../helpers/response_data_spec.rb             | 114 ++++++++++++++
 spec/spec_helper.rb                           |   2 +-
 17 files changed, 536 insertions(+), 47 deletions(-)
 create mode 100644 docs/response_data.md
 create mode 100644 lib/rspec/graphql_response/dig_dug/dig_dug.rb
 create mode 100644 lib/rspec/graphql_response/helpers/response_data.rb
 create mode 100644 spec/graphql_response/dig_dug/dig_dug_spec.rb
 create mode 100644 spec/graphql_response/helpers/response_data_spec.rb

diff --git a/Gemfile.lock b/Gemfile.lock
index 70cec2b..1e3b444 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,18 +1,22 @@
 PATH
   remote: .
   specs:
-    rspec-graphql_response (0.4.0)
+    rspec-graphql_response (0.4.1)
       graphql (>= 1.0)
       rspec (>= 3.0)
 
 GEM
   remote: https://rubygems.org/
   specs:
+    attr_extras (6.2.4)
     byebug (11.1.3)
     coderay (1.1.3)
     diff-lcs (1.4.4)
-    graphql (1.12.5)
+    graphql (1.12.6)
     method_source (1.0.0)
+    optimist (3.0.1)
+    patience_diff (1.2.0)
+      optimist (~> 3.0)
     pry (0.14.0)
       coderay (~> 1.1)
       method_source (~> 1.0)
@@ -33,6 +37,10 @@ GEM
       diff-lcs (>= 1.2.0, < 2.0)
       rspec-support (~> 3.10.0)
     rspec-support (3.10.2)
+    super_diff (0.6.1)
+      attr_extras (>= 6.2.4)
+      diff-lcs
+      patience_diff
 
 PLATFORMS
   ruby
@@ -43,6 +51,7 @@ DEPENDENCIES
   pry-byebug (~> 3.8)
   rake (>= 12.0)
   rspec-graphql_response!
+  super_diff (~> 0.6)
 
 BUNDLED WITH
    1.17.2
diff --git a/README.md b/README.md
index 14c71cc..31bfd61 100644
--- a/README.md
+++ b/README.md
@@ -55,7 +55,7 @@ Spec Helper Methods:
 
 - [execute_graphql](/docs/execute_graphql.md) - executes a graphql call with the registered schema, query, variables and context
 - [response](/docs/response.md) - the response, as JSON, of the executed graphql query
-- [operation](/docs/operation.md) - retrieves the results of a named operation from the GraphQL response
+- [response_data](/docs/response_data.md) - digs through the graphql response to return data from the specified node(s)
 
 API / Development
 
diff --git a/docs/operation.md b/docs/operation.md
index 1122e36..44288a9 100644
--- a/docs/operation.md
+++ b/docs/operation.md
@@ -1,38 +1,3 @@
 # Using the `operation` helper
 
-The `operation` helper will dig through a response to find a data
-structure that looks like, 
-
-```ruby
-{
-  "data" => {
-    operation_name
-  }
-}
-```
-
-## Basic Use
-
-```ruby
-it "has characters" do
-  characters = operation(:characters)
-
-  expect(character).to include(
-    { id: 1, name: "Jam" },
-    # ...
-  )
-end
-```
-
-## Handling Nil
-
-If there is no `"data"` or no named operation for the name supplied, the
-`operation` helper will return `nil`
-
-```ruby
-it "returns nil if operation doesn't exist" do
-  character = operation(:something_that_does_not_exist)
-
-  expect(operation).to be_nil
-end
-```
+Deprecated. See [response_data](response_data.md) instead.
diff --git a/docs/response.md b/docs/response.md
index f17a706..564a764 100644
--- a/docs/response.md
+++ b/docs/response.md
@@ -1 +1 @@
-# Check the GraphQL Response with Helper `response`
+# The GraphQL Response, via Helper `response`
diff --git a/docs/response_data.md b/docs/response_data.md
new file mode 100644
index 0000000..dae274b
--- /dev/null
+++ b/docs/response_data.md
@@ -0,0 +1,146 @@
+# Using the `response_data` Helper
+
+The `response_data` helper will dig through a graphql response, through
+the outer hash, into the response data for an operation, and through any
+and all layers of hash and array. 
+
+## Syntax
+
+```ruby
+response_data *[dig_pattern]
+```
+
+Data returned via this helper will assume a `"data" => ` key at the root of
+the `response` object. This root does not need to be specified in the list
+of attributes for the `dig_pattern`.
+
+### Params
+
+* `*[dig_pattern]` - an array of attributes (`:symbol`, `"string"`, or `key: :value` pair) that describes
+the data structure to dig through, and the final data set to retrieve from the graphql response.
+
+#### dig_pattern
+
+Each attribute added to the `dig_pattern` represents an attribute at the given level of the
+data structure, in numeric order from left to right. The first attribute provides will dig into
+that attribute at the first level of data (just below the `"data" =>` key). The second attribute
+will dig through data just below that first level, etc. etc. etc.
+
+For example, with a data structure as shown below, in "Basic Use", you could specifiy these
+attributes for the dig pattern:
+
+* :characters
+* :name
+
+Like this:
+
+```ruby
+response_data :characters, :name
+```
+
+This dig pattern will find the `"characters"` key just below `"data"`, then iterate through
+the array of characters and retrieve the `"name"` of each character.
+
+For more details and options for the dig pattern, see the examples below.
+
+## Basic Use
+
+A `response` data structure may look something like the following.
+
+```ruby
+{
+  "data" => {
+    "characters" => [
+      { "id" => "1", "name" => "Jam" },
+      { "id" => "2", "name" => "Redemption" },
+      { "id" => "3", "name" => "Pet" }
+    ]
+  }
+}
+```
+
+The `response_data` helper will dig through to give you simplified
+results that are easier to verify.
+
+For example, if only the names of the characters need to be checked:
+
+```ruby
+response_data :characters, :name
+
+# => ["Jam", "Redemption", "Pet"]
+```
+
+Or perhaps only the name for 2nd character is needed:
+
+```ruby
+response_data {characters: [1]}, :name
+
+# => "Redemption"
+```
+
+## List Every Item in an Array
+
+Many responses from a graphql call will include an array of data somewhere
+in the data structure. If you need to return all of the items in an array,
+you only need to specify that array's key:
+
+```ruby
+it "has characters" do
+  characters = response_data(:characters)
+
+  expect(character).to include(
+    { id: 1, name: "Jam" },
+    # ...
+  )
+end
+```
+
+## Dig a Field From Every Item in an Array
+
+When validation only needs to occur on a specific field for items found in
+an array, there are two options.
+
+1. Specify a list of fields as already shown
+2. change the array's key to a hash and provide a `:symbol` wrapped in an array as the value
+
+The first option was already shown in the Basic Use section above. 
+
+```ruby
+response_data :characters, :name
+
+# => ["Jam", "Redemption", "Pet"]
+```
+
+For the second option, the code would look like this:
+
+```ruby
+response_data characters: [:name]
+
+# => ["Jam", "Redemption", "Pet"]
+```
+
+Both of these options are functionaly the same. The primary difference will be
+how you wish to express the data structure in your code. Changing the list of
+attributes to a hash with an array wrapping the value will provide a better
+indication that an array is expected at that point in the data structure.
+
+## Dig Out an Item By Index, From an Array
+
+There may be times when only a single piece of a returned array needs to be
+validated. To handle this, switch the key of the array to a hash, as in the
+previous example. Rather than specifying a child node's key in the value, though,
+specify the index of the item you wish to extract.
+
+```ruby
+response_data characters: [1]
+```
+
+This will return the character at index 1, from the array of characters.
+
+## Handling Nil
+
+If there is no data the key supplied, the helper will return `nil`
+
+```ruby
+response_data(:something_that_does_not_exist) #=> nil
+```
diff --git a/lib/rspec/graphql_response.rb b/lib/rspec/graphql_response.rb
index c024f03..0379e09 100644
--- a/lib/rspec/graphql_response.rb
+++ b/lib/rspec/graphql_response.rb
@@ -1,5 +1,6 @@
 require "rspec"
 
+require_relative "graphql_response/dig_dug/dig_dug"
 require_relative "graphql_response/version"
 require_relative "graphql_response/configuration"
 require_relative "graphql_response/validators"
diff --git a/lib/rspec/graphql_response/dig_dug/dig_dug.rb b/lib/rspec/graphql_response/dig_dug/dig_dug.rb
new file mode 100644
index 0000000..aa004d9
--- /dev/null
+++ b/lib/rspec/graphql_response/dig_dug/dig_dug.rb
@@ -0,0 +1,83 @@
+module RSpec
+  module GraphQLResponse
+    class DigDug
+      attr_reader :dig_pattern
+
+      def initialize(*dig_pattern)
+        @dig_pattern = parse_dig_pattern(*dig_pattern)
+      end
+
+      def dig(data)
+        dig_data(data, dig_pattern)
+      end
+
+      private
+
+      def dig_data(data, patterns)
+        return data if patterns.nil?
+        return data if patterns.empty?
+
+        node = patterns[0]
+        node_key = node[:key]
+        node_key = node_key.to_s if node_key.is_a? Symbol
+        node_value = node[:value]
+
+        if node[:type] == :symbol
+          result = dig_symbol(data, node_key)
+        elsif node[:type] == :array
+          if data.is_a? Hash
+            child_data = data[node_key]
+            result = dig_symbol(child_data, node_value)
+          elsif data.is_a? Array
+            result = data.map { |value|
+              child_data = value[node_key]
+              dig_symbol(child_data, node_value)
+            }.compact
+          else
+            result = data
+          end
+        end
+
+        dig_data(result, patterns.drop(1))
+      end
+
+      def parse_dig_pattern(*pattern)
+        pattern_config = pattern.map do |pattern_item|
+          if pattern_item.is_a? Symbol 
+            {
+              type: :symbol,
+              key: pattern_item
+            }
+          elsif pattern_item.is_a? Hash
+            pattern_item.map do |key, value|
+              {
+                type: :array,
+                key: key,
+                value: value[0]
+              }
+            end
+          end
+        end
+
+        pattern_config.flatten
+      end
+
+      def dig_symbol(data, key)
+        key = key.to_s if key.is_a? Symbol
+        return data[key] if data.is_a? Hash
+
+        if data.is_a? Array
+          if key.is_a? Numeric
+            mapped_data = data[key]
+          else
+            mapped_data = data.map { |value| value[key] }.flatten
+          end
+
+          return mapped_data
+        end
+
+        return data
+      end
+    end
+  end
+end
diff --git a/lib/rspec/graphql_response/helpers.rb b/lib/rspec/graphql_response/helpers.rb
index ea57184..ac295f2 100644
--- a/lib/rspec/graphql_response/helpers.rb
+++ b/lib/rspec/graphql_response/helpers.rb
@@ -40,11 +40,12 @@ def self.add_helper(name, scope: :spec, &helper)
 end
 
 # describe level helpers
+require_relative "helpers/graphql_context"
 require_relative "helpers/graphql_operation"
 require_relative "helpers/graphql_variables"
-require_relative "helpers/graphql_context"
 
 # spec level helpers
+require_relative "helpers/execute_graphql"
 require_relative "helpers/operation"
 require_relative "helpers/response"
-require_relative "helpers/execute_graphql"
+require_relative "helpers/response_data"
diff --git a/lib/rspec/graphql_response/helpers/operation.rb b/lib/rspec/graphql_response/helpers/operation.rb
index 2bb2a70..d320687 100644
--- a/lib/rspec/graphql_response/helpers/operation.rb
+++ b/lib/rspec/graphql_response/helpers/operation.rb
@@ -1,4 +1,5 @@
 RSpec::GraphQLResponse.add_helper :operation do |name|
+  warn 'WARNING: operation has been deprecated in favor of response_data. This helper will be removed in v0.5'
   return nil unless response.is_a? Hash
 
   response.dig("data", name.to_s)
diff --git a/lib/rspec/graphql_response/helpers/response_data.rb b/lib/rspec/graphql_response/helpers/response_data.rb
new file mode 100644
index 0000000..961669b
--- /dev/null
+++ b/lib/rspec/graphql_response/helpers/response_data.rb
@@ -0,0 +1,13 @@
+RSpec::GraphQLResponse.add_helper :response_data do |*fields|
+  next nil unless response.is_a? Hash
+
+  response_data = response["data"]
+  next nil if response_data.nil?
+  next nil if response_data.empty?
+
+  fields = fields.compact
+  next response_data if fields.empty?
+
+  dig_dug = RSpec::GraphQLResponse::DigDug.new(*fields)
+  dig_dug.dig(response_data)
+end
diff --git a/lib/rspec/graphql_response/version.rb b/lib/rspec/graphql_response/version.rb
index 8b7b65e..06e5b21 100644
--- a/lib/rspec/graphql_response/version.rb
+++ b/lib/rspec/graphql_response/version.rb
@@ -1,5 +1,5 @@
 module RSpec
   module GraphQLResponse
-    VERSION = "0.4.0"
+    VERSION = "0.4.1"
   end
 end
diff --git a/rspec-graphql_response.gemspec b/rspec-graphql_response.gemspec
index 46c0263..c99772c 100644
--- a/rspec-graphql_response.gemspec
+++ b/rspec-graphql_response.gemspec
@@ -29,6 +29,7 @@ Gem::Specification.new do |spec|
   spec.add_development_dependency "rake", ">= 12.0"
   spec.add_development_dependency "pry", "~> 0.14"
   spec.add_development_dependency "pry-byebug", "~> 3.8"
+  spec.add_development_dependency "super_diff", "~> 0.6"
 
   spec.add_runtime_dependency "rspec", ">= 3.0"
   spec.add_runtime_dependency "graphql", ">= 1.0"
diff --git a/spec/graphql/queries/characters.rb b/spec/graphql/queries/characters.rb
index b6ab785..4b2c6f8 100644
--- a/spec/graphql/queries/characters.rb
+++ b/spec/graphql/queries/characters.rb
@@ -8,15 +8,25 @@ def resolve(name: nil)
       data = [
         {
           "id" => "1",
-          "name" => "Jam"
+          "name" => "Jam",
+          "friends" => [
+            { "id" => "2", "name" => "Redemption" }
+          ]
         },
         {
           "id" => "2",
-          "name" => "Redemption"
+          "name" => "Redemption",
+          "friends" => [
+            { "id" => "1", "name" => "Jam" },
+            { "id" => "3", "name" => "Pet" }
+          ]
         },
         {
           "id" => "3",
-          "name" => "Pet"
+          "name" => "Pet",
+          "friends" => [
+            { "id" => "2", "name" => "Redemption" }
+          ]
         }
       ]
 
diff --git a/spec/graphql/types/response/character.rb b/spec/graphql/types/response/character.rb
index 001a02e..d7e4bef 100644
--- a/spec/graphql/types/response/character.rb
+++ b/spec/graphql/types/response/character.rb
@@ -5,6 +5,7 @@ class Character < GraphQL::Schema::Object
 
       field :id, ID, null: false
       field :name, String, null: false
+      field :friends, [Character], null: false
     end
   end
 end
diff --git a/spec/graphql_response/dig_dug/dig_dug_spec.rb b/spec/graphql_response/dig_dug/dig_dug_spec.rb
new file mode 100644
index 0000000..b56eec9
--- /dev/null
+++ b/spec/graphql_response/dig_dug/dig_dug_spec.rb
@@ -0,0 +1,144 @@
+RSpec.describe RSpec::GraphQLResponse::DigDug do
+  let(:response) do
+    {
+      "characters" => [
+        {
+          "id" => "1",
+          "name" => "Jam",
+          "friends" => [
+            { "id" => "2", "name" => "Redemption" }
+          ]
+        },
+        {
+          "id" => "2",
+          "name" => "Redemption",
+          "friends" => [
+            { "id" => "1", "name" => "Jam" },
+            { "id" => "3", "name" => "Pet" }
+          ]
+        },
+        {
+          "id" => "3",
+          "name" => "Pet",
+          "friends" => [
+            { "id" => "2", "name" => "Redemption" }
+          ]
+        }
+      ]
+    }
+  end
+
+  let(:dig_pattern) { nil }
+
+  subject(:dig) do
+    dig_dug = described_class.new(*dig_pattern)
+    dig_dug.dig(response)
+  end
+
+  context "dig one layer" do
+    let(:dig_pattern) { [:characters] }
+
+    it "returns the correct data" do
+      expect(dig).to include(
+        {
+          "id" => "1",
+          "name" => "Jam",
+          "friends" => [
+            { "id" => "2", "name" => "Redemption" }
+          ]
+        },
+        {
+          "id" => "2",
+          "name" => "Redemption",
+          "friends" => [
+            { "id" => "1", "name" => "Jam" },
+            { "id" => "3", "name" => "Pet" }
+          ]
+        },
+        {
+          "id" => "3",
+          "name" => "Pet",
+          "friends" => [
+            { "id" => "2", "name" => "Redemption" }
+          ]
+        }
+      )
+    end
+  end
+
+  context "dig through an array" do
+    let(:dig_pattern) { [:characters, :friends] }
+
+    it "returns the correct data" do
+      expect(dig).to include(
+        { "id" => "2", "name" => "Redemption" },
+        { "id" => "1", "name" => "Jam" },
+        { "id" => "3", "name" => "Pet" },
+        { "id" => "2", "name" => "Redemption" }
+      )
+    end
+  end
+
+  context "dig through an array to nested fields" do
+    let(:dig_pattern) { [:characters, :friends, :name] }
+
+    it "returns the correct data" do
+      expect(dig).to include(
+        "Redemption",
+        "Jam",
+        "Pet"
+      )
+    end
+  end
+
+  context "dig into an Array at the specified index" do
+    let(:dig_pattern) { [characters: [1]] }
+
+    it "returns the correct data" do
+      expect(dig).to eq(
+        "id" => "2",
+        "name" => "Redemption",
+        "friends" => [
+          { "id" => "1", "name" => "Jam" },
+          { "id" => "3", "name" => "Pet" }
+        ]
+      )
+    end
+  end
+
+  context "dig multiple levels into an Array at the specified index" do
+    let(:dig_pattern) { [characters: [1], friends: [0]] }
+
+    it "returns the correct data" do
+      expect(dig).to include(
+        { "id" => "1", "name" => "Jam" }
+      )
+    end
+  end
+
+  context "dig into a Hash that came through an Array" do
+    let(:dig_pattern) { [characters: [0], friends: [:name]] }
+
+    it "returns the correct data" do
+      expect(dig).to eq(["Redemption"])
+    end
+  end
+
+  context "dig indexed item of value from hash that came through an array" do
+    let(:dig_pattern) { [:characters, friends: [1]] }
+
+    it "returns the correct data" do
+      expect(dig).to include(
+        { "id" => "3", "name" => "Pet" }
+      )
+    end
+  end
+
+  context "dig multiple nested levels of hash and Array" do
+    let(:dig_pattern) { [:characters, {friends: [1]}, :name] }
+
+    it "returns the correct data" do
+      expect(dig).to eq ["Pet"]
+    end
+  end
+end
diff --git a/spec/graphql_response/helpers/response_data_spec.rb b/spec/graphql_response/helpers/response_data_spec.rb
new file mode 100644
index 0000000..3bede89
--- /dev/null
+++ b/spec/graphql_response/helpers/response_data_spec.rb
@@ -0,0 +1,114 @@
+RSpec.describe RSpec::GraphQLResponse, "helper#response", type: :graphql do
+  graphql_operation <<-GQL
+    query {
+      characters {
+        id,
+        name,
+        friends {
+          id
+          name
+        }
+      }
+    }
+  GQL
+
+  context "has data returned" do
+    it "can return the hash" do
+      expect(response_data).to include(
+        "characters" => [
+          {
+            "id" => "1",
+            "name" => "Jam",
+            "friends" => [
+              { "id" => "2", "name" => "Redemption" }
+            ]
+          },
+          {
+            "id" => "2",
+            "name" => "Redemption",
+            "friends" => [
+              { "id" => "1", "name" => "Jam" },
+              { "id" => "3", "name" => "Pet" }
+            ]
+          },
+          {
+            "id" => "3",
+            "name" => "Pet",
+            "friends" => [
+              { "id" => "2", "name" => "Redemption" }
+            ]
+          }
+        ]
+      )
+    end
+
+    it "can dig to the first layer" do
+      expect(response_data :characters).to include(
+        {
+          "id" => "1",
+          "name" => "Jam",
+          "friends" => [
+            { "id" => "2", "name" => "Redemption" }
+          ]
+        },
+        {
+          "id" => "2",
+          "name" => "Redemption",
+          "friends" => [
+            { "id" => "1", "name" => "Jam" },
+            { "id" => "3", "name" => "Pet" }
+          ]
+        },
+        {
+          "id" => "3",
+          "name" => "Pet",
+          "friends" => [
+            { "id" => "2", "name" => "Redemption" }
+          ]
+        }
+      )
+    end
+
+    it "can dig through an array" do
+      expect(response_data :characters, :friends).to include(
+        { "id" => "2", "name" => "Redemption" },
+        { "id" => "1", "name" => "Jam" },
+        { "id" => "3", "name" => "Pet" },
+        { "id" => "2", "name" => "Redemption" }
+      )
+    end
+
+    it "can dig through an array to nested fields" do
+      expect(response_data :characters, :friends, :name).to include(
+        "Redemption",
+        "Jam",
+        "Pet"
+      )
+    end
+
+    it "can dig into an Array at the specified index" do
+      expect(response_data characters: [1]).to eq(
+        "id" => "2",
+        "name" => "Redemption",
+        "friends" => [
+          { "id" => "1", "name" => "Jam" },
+          { "id" => "3", "name" => "Pet" }
+        ]
+      )
+    end
+
+    it "can dig multiple levels into an Array at the specified index" do
+      expect(response_data characters: [1], friends: [0]).to include(
+        { "id" => "1", "name" => "Jam" },
+      )
+    end
+
+    it "can dig into a Hash that came through an Array" do
+      expect(response_data characters: [0], friends: [:name]).to eq(["Redemption"])
+    end
+
+    it "can dig multiple nested levels of hash and Array" do
+      expect(response_data(:characters, {friends: [1]}, :name)).to eq(["Pet"])
+    end
+  end
+end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index db09e26..fcc753a 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -1,5 +1,5 @@
 require "pry-byebug"
-
+require "super_diff/rspec"
 require "graphql"
 require "graphql/example_schema"
 require "rspec/graphql_response"