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