diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..49e42fe --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + include: + - gemfile: Gemfile + ruby: 3.3 + steps: + - run: echo BUNDLE_GEMFILE=${{ matrix.gemfile }} > $GITHUB_ENV + - uses: actions/checkout@v4 + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true # runs 'bundle install' and caches installed gems automatically + - name: Run tests + run: | + gem install bundler -v 2.4.22 + bundle install --jobs 4 --retry 3 + bundle exec rake test diff --git a/lib/shopify_custom_data_graphql/response_transformer.rb b/lib/shopify_custom_data_graphql/response_transformer.rb index 9069d63..0ec4e3d 100644 --- a/lib/shopify_custom_data_graphql/response_transformer.rb +++ b/lib/shopify_custom_data_graphql/response_transformer.rb @@ -3,6 +3,7 @@ module ShopifyCustomDataGraphQL class ResponseTransformer EMPTY_HASH = {}.freeze + SCOPED_FIELD = /^#{Regexp.quote(RequestTransformer::RESERVED_PREFIX)}([^_]+)_(.+)/.freeze def initialize(transform_map) @transform_map = transform_map @@ -10,6 +11,7 @@ def initialize(transform_map) def perform(result) result["data"] = transform_object_scope!(result["data"], @transform_map) if result["data"] + result["errors"] = transform_errors!(result["errors"]) if result["errors"] result end @@ -97,5 +99,39 @@ def transform_field_value(field_value, transform) MetafieldTypeResolver.resolve(transform_type, field_value, transform["s"]) end end + + def transform_errors!(errors) + errors.each do |error| + error.delete("locations") if @transform_map.any? + error["path"] = transform_error_path(error["path"]) if error["path"] + end + end + + def transform_error_path(path) + transformed_path = [] + path.reduce([0, @transform_map]) do |(index, current_map)| + key = path[index] + if key.is_a?(String) && key.start_with?(RequestTransformer::RESERVED_PREFIX) + m = key.match(SCOPED_FIELD) + parent_name = m[1] + child_name = m[2] + transformed_path.push(parent_name, child_name) + next_map = current_map.dig("f", parent_name, "f", child_name) + return transformed_path unless next_map + + if (next_transform = next_map.dig("fx", "t")) + next [index + 2, next_map] if MetafieldTypeResolver.reference?(next_transform) + end + [index + 1, next_map] + else + next_map = key.is_a?(String) ? current_map.dig("f", key) : current_map + return path unless next_map + + transformed_path << key + [index + 1, next_map] + end + end + transformed_path + end end end diff --git a/shopify_custom_data_graphql.gemspec b/shopify_custom_data_graphql.gemspec index bbbba34..aacf1d1 100644 --- a/shopify_custom_data_graphql.gemspec +++ b/shopify_custom_data_graphql.gemspec @@ -7,8 +7,8 @@ Gem::Specification.new do |spec| spec.name = "shopify_custom_data_graphql" spec.version = ShopifyCustomDataGraphQL::VERSION spec.authors = ["Greg MacWilliam"] - spec.summary = "A client for consuming Shopify metafields and metaobjects through schema projections." - spec.description = "Build a shop-specific GraphQL schema and use it to make requests." + spec.summary = "A statically-typed GraphQL API for Shopify Metafields and Metaobjects." + spec.description = "A statically-typed GraphQL API for Shopify Metafields and Metaobjects." spec.homepage = "https://github.com/gmac/shopify_custom_data_graphql" spec.license = "MIT" diff --git a/test/fixtures/casettes/errors_with_list_path.json b/test/fixtures/casettes/errors_with_list_path.json new file mode 100644 index 0000000..e73f939 --- /dev/null +++ b/test/fixtures/casettes/errors_with_list_path.json @@ -0,0 +1,39 @@ +{ + "errors": [ + { + "message": "Access denied for createdByStaff field.", + "locations": [ + { + "line": 11, + "column": 13 + } + ], + "path": [ + "products", + "nodes", + 0, + "___extensions_widget", + "reference", + "___system_createdByStaff" + ], + "extensions": { + "code": "ACCESS_DENIED" + } + } + ], + "data": { + "products": { + "nodes": [ + { + "extensions": { + "widget": { + "system": { + "createdByStaff": null + } + } + } + } + ] + } + } +} diff --git a/test/fixtures/casettes/errors_with_object_path.json b/test/fixtures/casettes/errors_with_object_path.json new file mode 100644 index 0000000..bf1189c --- /dev/null +++ b/test/fixtures/casettes/errors_with_object_path.json @@ -0,0 +1,33 @@ +{ + "errors": [ + { + "message": "Access denied for createdByStaff field.", + "locations": [ + { + "line": 8, + "column": 11 + } + ], + "path": [ + "product", + "___extensions_widget", + "reference", + "___system_createdByStaff" + ], + "extensions": { + "code": "ACCESS_DENIED" + } + } + ], + "data": { + "product": { + "extensions": { + "widget": { + "system": { + "createdByStaff": null + } + } + } + } + } +} diff --git a/test/fixtures/casettes/transforms_extensions_reference_list_fields.json b/test/fixtures/casettes/transforms_extensions_reference_list_fields.json new file mode 100644 index 0000000..afd7600 --- /dev/null +++ b/test/fixtures/casettes/transforms_extensions_reference_list_fields.json @@ -0,0 +1,29 @@ +{ + "data": { + "product": { + "id": "gid://shopify/Product/6885875646486", + "___extensions_fileReferenceList": { + "references": { + "nodes": [ + { + "id": "gid://shopify/MediaImage/20354823356438", + "alt": "A scenic landscape" + } + ] + } + }, + "___extensions_productReferenceList": { + "references": { + "edges": [ + { + "node": { + "id": "gid://shopify/Product/6561850556438", + "title": "Aquanauts Crystal Explorer Sub" + } + } + ] + } + } + } + } +} diff --git a/test/fixtures/casettes/transforms_extensions_typename.json b/test/fixtures/casettes/transforms_extensions_typename.json new file mode 100644 index 0000000..4244fba --- /dev/null +++ b/test/fixtures/casettes/transforms_extensions_typename.json @@ -0,0 +1,9 @@ +{ + "data": { + "product": { + "__typename": "Product", + "extensions": "Product", + "___extensions___typename": "Product" + } + } +} diff --git a/test/shopify_custom_data_graphql/request_transformer_test.rb b/test/shopify_custom_data_graphql/request_transformer_test.rb index 2bc0e78..163025f 100644 --- a/test/shopify_custom_data_graphql/request_transformer_test.rb +++ b/test/shopify_custom_data_graphql/request_transformer_test.rb @@ -1181,7 +1181,7 @@ def transform_request(shop_query, variables: {}, operation_name: nil, schema: sh ) errors = query.schema.static_validator.validate(query)[:errors] - refute errors.any?, "Invalid metafields query: #{errors.first.message}" if errors.any? + refute errors.any?, "Invalid custom data query: #{errors.first.message}" if errors.any? result = ShopifyCustomDataGraphQL::RequestTransformer.new(query).perform # validate transformed query against base admin schema diff --git a/test/shopify_custom_data_graphql/response_transformer_test.rb b/test/shopify_custom_data_graphql/response_transformer_test.rb index ff225e5..839fa69 100644 --- a/test/shopify_custom_data_graphql/response_transformer_test.rb +++ b/test/shopify_custom_data_graphql/response_transformer_test.rb @@ -89,6 +89,65 @@ def test_transforms_extensions_reference_fields assert_equal expected, result.dig("data") end + + def test_transforms_extensions_reference_list_fields + result = fetch("transforms_extensions_reference_list_fields", %|query { + product(id: "#{PRODUCT_ID}") { + id + extensions { + fileReferenceList(first: 10, after: "r2d2") { + nodes { id alt } + } + productReferenceList(last: 10, before: "c3p0") { + edges { node { id title } } + } + } + } + }|) + + expected = { + "product" => { + "id" => PRODUCT_ID, + "extensions" => { + "fileReferenceList" => { + "nodes" => [{ + "id" => "gid://shopify/MediaImage/20354823356438", + "alt" => "A scenic landscape", + }], + }, + "productReferenceList" => { + "edges" => [{ + "node" => { + "id" => "gid://shopify/Product/6561850556438", + "title" => "Aquanauts Crystal Explorer Sub", + }, + }], + }, + }, + }, + } + + assert_equal expected, result.dig("data") + end + + def test_transforms_extensions_typename + result = fetch("transforms_extensions_typename", %|query { + product(id: "#{PRODUCT_ID}") { + __typename + extensions { __typename } + } + }|) + + expected = { + "product" => { + "__typename" => "Product", + "extensions" => { "__typename" => "ProductExtensions" }, + }, + } + + assert_equal expected, result.dig("data") + end + def test_transforms_mixed_reference_with_matching_type_selection result = fetch("mixed_reference_returning_taco", %|query { product(id: "1") { @@ -142,6 +201,52 @@ def test_transforms_mixed_reference_without_matching_type_selection assert_equal expected, result.dig("data") end + def test_transforms_errors_with_object_paths + result = fetch("errors_with_object_path", %|query { + product(id: "#{PRODUCT_ID}") { + extensions { + widget { + system { + createdByStaff { name } + } + } + } + } + }|) + + expected_errors = [{ + "message" => "Access denied for createdByStaff field.", + "path" => ["product", "extensions", "widget", "system", "createdByStaff"], + "extensions" => { "code" => "ACCESS_DENIED" }, + }] + + assert_equal expected_errors, result.dig("errors") + end + + def test_transforms_errors_with_list_paths + result = fetch("errors_with_list_path", %|query { + products(first: 1) { + nodes { + extensions { + widget { + system { + createdByStaff { name } + } + } + } + } + } + }|) + + expected_errors = [{ + "message" => "Access denied for createdByStaff field.", + "path" => ["products", "nodes", 0, "extensions", "widget", "system", "createdByStaff"], + "extensions" => { "code" => "ACCESS_DENIED" }, + }] + + assert_equal expected_errors, result.dig("errors") + end + private def fetch(fixture, document, variables: {}, operation_name: nil, schema: nil) @@ -152,7 +257,8 @@ def fetch(fixture, document, variables: {}, operation_name: nil, schema: nil) operation_name: operation_name, ) - assert query.schema.static_validator.validate(query)[:errors].none?, "Invalid shop query." + errors = query.schema.static_validator.validate(query)[:errors] + 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)