diff --git a/README.md b/README.md index fa9b9a3..6804495 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,119 @@ shop.with_shopify_session do end ``` +## Configuration + +### Case Conversion + +The gem supports automatic case conversion between Ruby's snake_case conventions and GraphQL's camelCase conventions. This feature is disabled by default to maintain backward compatibility. + +To enable case conversion, configure it in your initializer: + +```rb +# config/initializers/shopify_graphql.rb +ShopifyGraphql.configure do |config| + config.convert_case = true +end +``` + +When enabled, the gem will: +- Convert snake_case variables to camelCase when sending GraphQL requests +- Convert camelCase response keys to snake_case for easier access in Ruby + +
Example: Request Variable Conversion + +```rb +# With convert_case = true +class GetProduct + include ShopifyGraphql::Query + + QUERY = <<~GRAPHQL + query($product_id: ID!, $include_variants: Boolean!) { + product(id: $product_id) { + title + variants(first: 10) @include(if: $include_variants) { + edges { + node { + displayName + } + } + } + } + } + GRAPHQL + + def call(product_id:, include_variants: false) + # Variables are automatically converted: product_id → productId, include_variants → includeVariants + response = execute(QUERY, product_id: product_id, include_variants: include_variants) + response.data.product + end +end + +# Usage +product = GetProduct.call( + product_id: "gid://shopify/Product/12345", + include_variants: true +) +``` + +
+ +
Example: Response Key Conversion + +```rb +# With convert_case = true +class GetProduct + include ShopifyGraphql::Query + + QUERY = <<~GRAPHQL + query($id: ID!) { + product(id: $id) { + title + featuredImage { + originalSrc + altText + } + variants(first: 5) { + edges { + node { + displayName + selectedOptions { + name + value + } + } + } + } + } + } + GRAPHQL + + def call(id:) + response = execute(QUERY, id: id) + response.data.product + end +end + +# Usage - all keys are automatically converted to snake_case +product = GetProduct.call(id: "gid://shopify/Product/12345") + +puts product.title +puts product.featured_image.original_src # originalSrc → original_src +puts product.featured_image.alt_text # altText → alt_text + +product.variants.edges.each do |edge| + puts edge.node.display_name # displayName → display_name + + edge.node.selected_options.each do |option| + puts "#{option.name}: #{option.value}" # Keys converted automatically + end +end +``` + +
+ +**Note:** Case conversion adds a small performance overhead due to key transformation. Enable it only if you prefer Ruby naming conventions over GraphQL's camelCase. + ## Conventions To better organize your Graphql code use the following conventions: diff --git a/lib/shopify_graphql/client.rb b/lib/shopify_graphql/client.rb index 6749710..500d415 100644 --- a/lib/shopify_graphql/client.rb +++ b/lib/shopify_graphql/client.rb @@ -30,6 +30,7 @@ def client end def execute(query, headers: nil, **variables) + variables = convert_variables_to_camel_case(variables) if ShopifyGraphql.configuration.convert_case response = client.query(query: query, variables: variables, headers: headers) Response.new(handle_response(response)) rescue ShopifyAPI::Errors::HttpResponseError => e @@ -48,7 +49,11 @@ def execute(query, headers: nil, **variables) def parsed_body(response) if response.body.is_a?(Hash) - JSON.parse(response.body.to_json, object_class: OpenStruct) + if ShopifyGraphql.configuration.convert_case + JSON.parse(response.body.deep_transform_keys(&:underscore).to_json, object_class: OpenStruct) + else + JSON.parse(response.body.to_json, object_class: OpenStruct) + end else response.body end @@ -169,6 +174,12 @@ def generate_user_errors_message(messages: nil, codes: nil, fields: []) result.join(" ") end end + + private + + def convert_variables_to_camel_case(variables) + variables.deep_transform_keys { |key| key.to_s.camelize(:lower) } + end end class << self diff --git a/lib/shopify_graphql/configuration.rb b/lib/shopify_graphql/configuration.rb index 3e86d2c..8ee2b26 100644 --- a/lib/shopify_graphql/configuration.rb +++ b/lib/shopify_graphql/configuration.rb @@ -4,10 +4,12 @@ class Configuration attr_accessor :webhook_jobs_namespace attr_accessor :webhook_enabled_environments attr_accessor :webhooks_manager_queue_name + attr_accessor :convert_case def initialize @webhooks_manager_queue_name = Rails.application.config.active_job.queue_name @webhook_enabled_environments = ['production'] + @convert_case = false end def has_webhooks? diff --git a/test/configuration_test.rb b/test/configuration_test.rb new file mode 100644 index 0000000..9d8434a --- /dev/null +++ b/test/configuration_test.rb @@ -0,0 +1,283 @@ +require "test_helper" + +class ConfigurationTest < ActiveSupport::TestCase + setup do + # Store the original configuration to restore it later + @original_config = ShopifyGraphql.configuration.convert_case + end + + teardown do + # Restore original configuration + ShopifyGraphql.configuration.convert_case = @original_config + end + + test "convert_case defaults to false" do + config = ShopifyGraphql::Configuration.new + assert_equal false, config.convert_case + end + + test "convert_case can be set to true" do + ShopifyGraphql.configure do |config| + config.convert_case = true + end + + assert_equal true, ShopifyGraphql.configuration.convert_case + end + + test "case conversion works with snake_case variables" do + ShopifyGraphql.configure { |config| config.convert_case = true } + + query = "query($first_name: String!) { shop { name } }" + variables = { first_name: "John" } + + fake("queries/shop.json", query, firstName: "John") + + client = ShopifyGraphql::Client.new + response = client.execute(query, **variables) + + assert_equal "Graphql Gem Test", response.data.shop.name + end + + test "response keys are converted to snake_case when enabled" do + ShopifyGraphql.configure { |config| config.convert_case = true } + + query = "{ shop { name myshopifyDomain } }" + fake("queries/shop.json", query) + + client = ShopifyGraphql::Client.new + response = client.execute(query) + + # Response should have snake_case keys + assert_equal "Graphql Gem Test", response.data.shop.name + assert_equal "graphql-gem-test.myshopify.com", response.data.shop.myshopify_domain + end + + test "original behavior when convert_case is disabled" do + ShopifyGraphql.configure { |config| config.convert_case = false } + + query = "{ shop { name myshopifyDomain } }" + fake("queries/shop.json", query) + + client = ShopifyGraphql::Client.new + response = client.execute(query) + + # Response should maintain original camelCase keys + assert_equal "Graphql Gem Test", response.data.shop.name + assert_equal "graphql-gem-test.myshopify.com", response.data.shop.myshopifyDomain + end + + test "nested structures are converted to snake_case when enabled" do + ShopifyGraphql.configure { |config| config.convert_case = true } + + query = <<~GRAPHQL + query($id: ID!) { + product(id: $id) { + title + featuredImage { + originalSrc + altText + transformedSrc + } + variants(first: 5) { + edges { + node { + displayName + selectedOptions { + name + value + } + price { + amount + currencyCode + } + } + } + } + metafields(first: 10) { + edges { + node { + namespace + key + value + createdAt + updatedAt + } + } + } + seo { + title + description + } + } + } + GRAPHQL + + fake("queries/nested_product.json", query, id: "gid://shopify/Product/12345") + + client = ShopifyGraphql::Client.new + response = client.execute(query, id: "gid://shopify/Product/12345") + product = response.data.product + + # Test top-level object + assert_equal "Complex Product", product.title + + # Test nested object keys converted to snake_case + assert_equal "https://example.com/image.jpg", product.featured_image.original_src + assert_equal "Product image", product.featured_image.alt_text + assert_equal "https://example.com/image_400x400.jpg", product.featured_image.transformed_src + + # Test array of nested objects + assert_equal 2, product.variants.edges.length + + first_variant = product.variants.edges.first.node + assert_equal "Small / Red", first_variant.display_name + assert_equal "29.99", first_variant.price.amount + assert_equal "USD", first_variant.price.currency_code + + # Test deeply nested arrays + assert_equal 2, first_variant.selected_options.length + assert_equal "Size", first_variant.selected_options.first.name + assert_equal "Small", first_variant.selected_options.first.value + + # Test multiple levels of nesting + second_variant = product.variants.edges.last.node + assert_equal "Large / Blue", second_variant.display_name + assert_equal "https://example.com/variant2.jpg", second_variant.image.original_src + assert_equal "Blue variant", second_variant.image.alt_text + + # Test metafields array conversion + assert_equal 2, product.metafields.edges.length + first_metafield = product.metafields.edges.first.node + assert_equal "care_instructions", first_metafield.key + assert_equal "Machine wash cold", first_metafield.value + assert_equal "2024-01-01T00:00:00Z", first_metafield.created_at + assert_equal "2024-01-02T00:00:00Z", product.metafields.edges.last.node.updated_at + + # Test simple nested object + assert_equal "SEO Title", product.seo.title + assert_equal "SEO Description", product.seo.description + end + + test "nested structures maintain camelCase when convert_case is disabled" do + ShopifyGraphql.configure { |config| config.convert_case = false } + + query = <<~GRAPHQL + query($id: ID!) { + product(id: $id) { + title + featuredImage { + originalSrc + altText + } + variants(first: 5) { + edges { + node { + displayName + selectedOptions { + name + value + } + } + } + } + } + } + GRAPHQL + + fake("queries/nested_product.json", query, id: "gid://shopify/Product/12345") + + client = ShopifyGraphql::Client.new + response = client.execute(query, id: "gid://shopify/Product/12345") + product = response.data.product + + # Keys should maintain original camelCase + assert_equal "https://example.com/image.jpg", product.featuredImage.originalSrc + assert_equal "Product image", product.featuredImage.altText + assert_equal "Small / Red", product.variants.edges.first.node.displayName + assert_equal "Size", product.variants.edges.first.node.selectedOptions.first.name + end + + test "empty arrays and nil values handled correctly with case conversion" do + ShopifyGraphql.configure { |config| config.convert_case = true } + + # Create a fixture with empty/nil values + empty_response = { + data: { + product: { + title: "Empty Product", + featuredImage: nil, + variants: { + edges: [] + }, + metafields: { + edges: [] + } + } + } + } + + query = "{ product { title featuredImage variants { edges } metafields { edges } } }" + stub_request(:post, "https://test-shop.myshopify.com/admin/api/2024-07/graphql.json") + .with(body: { query: query, variables: {} }) + .to_return(body: empty_response.to_json) + + client = ShopifyGraphql::Client.new + response = client.execute(query) + product = response.data.product + + assert_equal "Empty Product", product.title + assert_nil product.featured_image + assert_equal [], product.variants.edges + assert_equal [], product.metafields.edges + end + + test "mixed data types preserved with case conversion" do + ShopifyGraphql.configure { |config| config.convert_case = true } + + mixed_response = { + data: { + product: { + title: "Mixed Product", + isActive: true, + priceRange: { + minVariantPrice: { + amount: "10.50", + currencyCode: "USD" + }, + maxVariantPrice: { + amount: "25.99", + currencyCode: "USD" + } + }, + tags: ["summer", "sale", "new-arrival"], + productType: "Clothing", + totalInventory: 100 + } + } + } + + query = "{ product { title isActive priceRange { minVariantPrice { amount currencyCode } maxVariantPrice { amount currencyCode } } tags productType totalInventory } }" + stub_request(:post, "https://test-shop.myshopify.com/admin/api/2024-07/graphql.json") + .with(body: { query: query, variables: {} }) + .to_return(body: mixed_response.to_json) + + client = ShopifyGraphql::Client.new + response = client.execute(query) + product = response.data.product + + # Boolean preserved + assert_equal true, product.is_active + + # Nested objects converted + assert_equal "10.50", product.price_range.min_variant_price.amount + assert_equal "USD", product.price_range.min_variant_price.currency_code + assert_equal "25.99", product.price_range.max_variant_price.amount + + # Arrays preserved + assert_equal ["summer", "sale", "new-arrival"], product.tags + + # String and numbers preserved + assert_equal "Clothing", product.product_type + assert_equal 100, product.total_inventory + end +end \ No newline at end of file diff --git a/test/fixtures/queries/nested_product.json b/test/fixtures/queries/nested_product.json new file mode 100644 index 0000000..94f6157 --- /dev/null +++ b/test/fixtures/queries/nested_product.json @@ -0,0 +1,108 @@ +{ + "data": { + "product": { + "id": "gid://shopify/Product/12345", + "title": "Complex Product", + "featuredImage": { + "originalSrc": "https://example.com/image.jpg", + "altText": "Product image", + "transformedSrc": "https://example.com/image_400x400.jpg" + }, + "variants": { + "edges": [ + { + "node": { + "id": "gid://shopify/ProductVariant/1", + "displayName": "Small / Red", + "selectedOptions": [ + { + "name": "Size", + "value": "Small" + }, + { + "name": "Color", + "value": "Red" + } + ], + "price": { + "amount": "29.99", + "currencyCode": "USD" + }, + "image": { + "originalSrc": "https://example.com/variant1.jpg", + "altText": "Red variant" + } + } + }, + { + "node": { + "id": "gid://shopify/ProductVariant/2", + "displayName": "Large / Blue", + "selectedOptions": [ + { + "name": "Size", + "value": "Large" + }, + { + "name": "Color", + "value": "Blue" + } + ], + "price": { + "amount": "34.99", + "currencyCode": "USD" + }, + "image": { + "originalSrc": "https://example.com/variant2.jpg", + "altText": "Blue variant" + } + } + } + ], + "pageInfo": { + "hasNextPage": false, + "hasPreviousPage": false + } + }, + "metafields": { + "edges": [ + { + "node": { + "id": "gid://shopify/Metafield/1", + "namespace": "custom", + "key": "care_instructions", + "value": "Machine wash cold", + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z" + } + }, + { + "node": { + "id": "gid://shopify/Metafield/2", + "namespace": "inventory", + "key": "low_stock_threshold", + "value": "5", + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-02T00:00:00Z" + } + } + ] + }, + "seo": { + "title": "SEO Title", + "description": "SEO Description" + } + } + }, + "extensions": { + "cost": { + "requestedQueryCost": 15, + "actualQueryCost": 15, + "throttleStatus": { + "maximumAvailable": 1000, + "currentlyAvailable": 985, + "restoreRate": 50 + } + } + } +} \ No newline at end of file