GraphQL Api is a Hot Mess

Here are my thoughts after extensive development for migrating SEO King from the Rest API to the GraphQL Api:

  1. Extremely difficult to build reusable functions that work with different types of data since the nodes (field names) vary widely. Many calls that are made to products, pages, articles and collections must be built totally separately.

  2. Field names are different, missing and/or lacking coherent SYMMETRY and logic (with the rest api, and between end points WITHIN the graph api).

  3. The requirements of edges vs nodes vs node (or no explicit node) is beyond comprehension and can only be figured out by running specific tests.

  4. Due to the syntax of GraphQL, generating queries is very complicated and requires AI to generate specialized builder functions (luckily we have this)

  5. GraphQL and RestAPI are not at feature parity.

  6. There are solutions that are possible using the Rest Api which are not possible with the Graph Api - and some may be possible but the workarounds are insane (such as the productsCount issue).

  7. I was forced to build a “bridge” module to combine data from Rest and Graph (ie. body_html -><-- descriptionHtml). It is also a requirement to build a type of module that converts Graph data (particularly property names) to Rest data. Using Graph field naming as the new ground truth is highly problematic because it lacks consistency across different end points, unlike the Rest Api.

  8. The graph api has made me think that I should have separated out the fields names from the front and back end when I first started - but who would have thought that I good idea since it adds complexity? Answer: only app developers that were building apps for multiple platforms would have thought of this.

  9. GraphQL is essentially a different platform than Rest. So perhaps the BENEFIT here for existing Shopify developers is realizing that they can redesign their apps in a way to separate the APIs from the core functionality, and then porting their apps to different platforms such as Woocommerce.

  10. It is very clear that each GraphQL Api endpoint was designed by a different team working remotely and with minimal coordination with other graphql teams. It also seems to have been built by people that do not use it themselves to build full-featured applications. They must be running very limited tests that do not consider wide ranging edge-cases.

  11. Try asking ChatGPT o3 questions about building very specific queries for this API. It’s constantly hallucinating because the api lacks logic (symmetry).

  12. Graphql Api may be the worst API ever publicly deployed by any large tech company.

  13. The Graphql documentation is also poorly written compared to the Rest Api.

  14. The webhooks api results and the Graphql api results are not the same, this is also insane. Please do not change the webhooks API to graphql for the risk of collapsing everything.

Solutions

  1. Let us keep using the Rest Api (+ GraphQL Apis) until it has feature parity and in the meantime Shopify should hire an independent api expert to sort this out.

I wont go into all the specific issues in the graph api because this is too time consuming. It sure would be nice to not have to write this lame post at all.

Thank you for postposing the GraphQL DEADLINE, after scaring the hell out of us for 10 months and waiting 10 months to fix a simple issue like productsCount.

11 Likes

Hey Jason,

Firstly, thanks for collecting all this feedback and posting it here. It’s extremely valuable for us to receive this direct feedback from devs who have performed full migrations of their apps.

I hear you on some of the inconsistencies within the GraphQL Admin API which results in a lack of predictability when structuring queries. We are working to improve the reliability and logic of our API overall, so you can expect to see upgrades in this experience.

You pointed out that the GraphQL documentation is also poorly written compared to the Rest API - could you point to an example where the GraphQL docs fell short compared to the REST equivalent?

1 Like

I’m not saying that the GraphQL isn’t without any value at all. When you make the right calls, it is fast and does return more accurate error messages. But truth be told the main reason developers are using it, is because of of the features which are not available on the REST api. It’s a sneaky way of pushing devs to use the GraphQl Api. If both apis had perfectly equal features, Graphql usage would be very low.

If I had an app that only made a few different calls to the api, I suppose it would be fine to create a few explicit graph queries and reuse those.

However SEO King makes calls across 10-20 different endpoints with different permutations of queries and variables.

Below is half of my GraphQl class i had to write - i can’t fit the entire class within the 32000 char limit of this post. My equivalent rest class requires 75% less code and it manages 90% of what i need, while my GraphQl class is only built to manage a few endpoints (mostly files and translations) along with the new products integrations which is not working well enough to use in production.

By the way the productsCount issue isn’t some trivial problem. Shopify built an api for an e-commerce platform that wasn’t able to retrieve an accurate count of products FOR YEARS. This is indicative of some larger systemic problem.

	/**
	 * Convert camelCase to snake_case
	 * 
	 * @param string $string The string to convert
	 * @return string
	 */
	public function camelToSnake($string) {
		return strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $string));
	}

	/**
	 * Convert snake_case to camelCase
	 * 
	 * @param string $string The string to convert
	 * @param bool $capitalizeFirst Whether to capitalize the first character (pascalCase)
	 * @return string
	 */
	public function snakeToCamel($string, $capitalizeFirst = false) {
		$result = str_replace('_', '', ucwords($string, '_'));
		
		if (!$capitalizeFirst) {
			$result = lcfirst($result);
		}
		
		return $result;
	}
	
	/**
	 * Convert GraphQL response data to REST-like format
	 * Handles both edges/node and nodes patterns
	 * Recursively converts all keys from camelCase to snake_case
	 */
	public function convertDataToRest($data, $snake_case = true) {

		if (empty($data) || !is_array($data)) {
			return []; // $data;
		}

		# If we need to remove the wrapper key (e.g. 'products', 'collections', etc.)
		if (count($data) === 1) {
			$data = reset($data);
		}
		
		# Helper function to extract and convert node data
		$extractNodeData = function($item) use ($snake_case) {
			# If this is an edge, get the node
			if (isset($item['node'])) {
				$item = $item['node'];
			}
			
			# Return strings as-is			
			if (is_string($item)) {
				return $item;
			}

			# Convert keys from camelCase to snake_case recursively
			$converted = [];
			foreach ($item as $key => $value) {
				# Skip pageInfo and internal GraphQL fields
				if ($key === 'pageInfo' || $key === '__typename') {
					continue;
				}
				
				# Special hack for image alt texts
				if ($key === 'altText') {
					$converted['alt'] = $value;
					unset($item['altText']);
					continue;
				}

				# Convert the current key
				if ($snake_case) {
					$snakeKey = $this->camelToSnake($key);
				} else {
					$snakeKey = $key;
				}				

				# Handle nested arrays recursively
				if (is_array($value)) {
					if ($snakeKey === 'image') {
						# Special handling for image data
						$value = $this->convertMediaImageToRest($value);
					} else if (isset($value['edges'])) {
						# If this has edges, extract the nodes
						$value = array_map(function($edge) use ($snake_case) {
							return $this->convertDataToRest($edge['node'], $snake_case);
						}, $value['edges']);
					} else if (isset($value['nodes'])) {
						# If this has nodes, process them directly
						$value = array_map(function($node) use ($snake_case) {
							return $this->convertDataToRest($node, $snake_case);
						}, $value['nodes']);
					} else {
						# Convert every array element recursively
						$value = array_map(function($item) use ($snake_case) {
							return is_array($item) ? $this->convertDataToRest($item, $snake_case) : $item;
						}, $value);

						# If the array itself has keys that need converting (associative array)
						if (isAssociativeArray($value)) {
							$tempValue = [];
							foreach ($value as $k => $v) {
								$tempValue[$snake_case ? $this->camelToSnake($k) : $k] = $v;
							}
							$value = $tempValue;
						}
					}
				}

				$converted[$snakeKey] = $value;

				# Handle ID fields - duplicate to admin_graphql_api_id and convert id to basename
				if ($snakeKey === 'id') {
					$converted['admin_graphql_api_id'] = $value;
					$converted['id'] = getFilename($value);
				}
			}

			return $converted;
		};

		# Handle both edges and nodes patterns
		if (isset($data['edges'])) {
			return array_map($extractNodeData, $data['edges']);
		} 
		
		if (isset($data['nodes'])) {
			return array_map($extractNodeData, $data['nodes']);
		}

		# If it's just a regular array with no edges/nodes
		if (is_array($data)) {
			# If every value is an array, process each one
			if (count($data) > 0 && array_reduce($data, function($carry, $item) {
				return $carry && is_array($item);
			}, true)) {
				return array_map($extractNodeData, $data);
			}

			# Single item
			return $extractNodeData($data);
		}

		return [];
	}

	public function convertProductToGraph($rest_data) {
		/// Used to update some specific fields on a product

		//// body_html -> description_html
		if ( isset($rest_data['body_html']) ) {
			$rest_data['description_html'] = $rest_data['body_html'];
			unset($rest_data['body_html']);
		}
		
		//// published -> status (ACTIVE vs DRAFT)
		if ( isset($rest_data['published']) ) {
			$rest_data['status'] = $rest_data['published'] ? "ACTIVE" : "DRAFT";
			unset($rest_data['published']);
		}

		//// if handle is included, set redirect_new_handle = false
		if ( isset($rest_data['handle']) ) {
			$rest_data['redirect_new_handle'] = false;
		}

		foreach ($rest_data as $key => $value) {
			# convert keys to camelCase
			$graph_data[$this->snakeToCamel($key)] = $value;
		}

		return $graph_data;
	}

	# Convert data that comes from GraphQL to Rest Format - Do not include product wrapper
	public function convertProductToRest($graph_data) {

		if ( empty($graph_data) ) {
			return [];
		}
	
		# convert keys to snake_case
		$rest_data = $this->convertDataToRest($graph_data, true);

		# set some specific keys
		$rest_data['id'] = getFilename($graph_data['id'] ?? "");
		$rest_data['admin_graphql_api_id'] = $graph_data['id'] ?? "";
		$rest_data['published_status'] = $this->convertStatusToPublishedStatus($graph_data['status'] ?? "");
		$rest_data['body_html'] = $graph_data['descriptionHtml'] ?? "";

		# status is in lowercase in Rest api
		$rest_data['status'] = strtolower($graph_data['status'] ?? "");

		# tags are comma separated with a space
		$rest_data['tags'] = implode(", ", $graph_data['tags'] ?? []);

		# Product Category
		$rest_data['product_category'] = $graph_data['productCategory']['productTaxonomyNode']['name'] ?? "";
		$rest_data['product_category_full'] = $graph_data['productCategory']['productTaxonomyNode']['fullName'] ?? "";
		$rest_data['product_category_id'] = $graph_data['productCategory']['productTaxonomyNode']['id'] ?? "";
		
		# loop images and convert them

		# loop variants and convert them

		# remove unecessary keys
		unset($rest_data['description_html']);
		
		return $rest_data;

	}
	
	# Convert data that is in REST format to GraphQL format for updating
	public function convertRestProductToGraph($data) {

		/// body_html -> descriptionHtml
		/// published_status -> convertPublishedStatusToStatus -> status
		/// 
	}

	/**
     * Parse image object from GraphQL response
     */
    public function convertMediaImageToRest($node) {

		if ( empty($node) || !is_array($node) || empty($node['image']['src'] ?? $node['src'] ?? null) ) {
			return [];
		}

        return [
            'id' => getFilename($node['id']) ?? null,
			'admin_graphql_api_id' => $node['admin_graphql_api_id'] ?? $node['id'] ?? null,
            'src' => $node['image']['src'] ?? $node['src'],  /// Must be present
            'width' => $node['image']['width'] ?? $node['width'] ?? null,
            'height' => $node['image']['height'] ?? $node['height'] ?? null,
            'alt' => $node['image']['alt'] ?? $node['alt'] ??  "", // $node['image']['altText'] ?? $node['altText'] ??  "",
            'created_at' => $node['createdAt'] ?? null,
			'updated_at' => $node['updatedAt'] ?? null
        ];
    }

	# Convert data that comes from GraphQL to Rest Format
	public function convertVariantDataToRestFormat($data) {
		

	}


	public function convertStatusToPublishedStatus($status) {
		
		if ( empty($status) ) {
			return "";	
		} else if ( $status == "ACTIVE" ) {
			return "published";
		} else {
			return "unpublished";
		}

	}

	public function convertPublishedStatusToStatus($published_status) {
		
		if ( $published_status == "any" ) {
			return "ACTIVE,DRAFT";
		} else if ( $published_status == "published" ) {
			return "ACTIVE";
		} else {
			return "DRAFT";
		}
	
	}

    


	/////////////////// PRODUCT OPERATIONS ///////////////////

	/**
	 * Get standard product fields for GraphQL queries
	 * @return array Array of product fields
	 */
	public function productFieldsArray() {
		
		return [
			'id',
			'descriptionHtml',

			'title',
			'handle',
			'productType',
			'tags',
			'vendor',

			'createdAt',
			'updatedAt',
			'publishedAt',

			'onlineStoreUrl',
			'totalInventory',
			'status',

			// 'defaultCursor', // A default cursor that returns the single next record, sorted ascending by ID.

			'images' => [
				'__args' => true,
				'nodes' => [
					'id',
					'width',
					'height',
					'src',
					'altText'
				]			
			],

			'options' => [
				'__args' => true,
				'id',
				'name',
				'values'
			],

			'variants' => [
				'__args' => true,
				'nodes' => [
					'id',
					'displayName',
					'title',
					'price',
					'compareAtPrice',
					'inventoryQuantity',
					'inventoryPolicy',
					'availableForSale', // Doesn't seem to be added, but no errors
					'sku',
					'barcode',
					'position',
					'unitPriceMeasurement' => [
						'measuredType',
						'quantityUnit',
						'quantityValue',
						'referenceUnit',
						'referenceValue'
					],					
				]		
			],

			'productCategory' => [
				'productTaxonomyNode' => [
					'fullName',
					'name',
					'id'
				]
			]
			
		];
		
	}
	
			
	public function variantFieldsArray() {
		
		return [			
			'id',
			'displayName',
			'title',
			'price',
			'compareAtPrice',
			'inventoryQuantity',
			'inventoryPolicy',
			'availableForSale', // Doesn't seem to be added, but no errors
			'sku',
			'barcode',
			'position',
			'unitPriceMeasurement' => [
				'measuredType',
				'quantityUnit',
				'quantityValue',
				'referenceUnit',
				'referenceValue'
			],
			'image' => $this->mediaImageFieldsArray()['image']
		];

	}

	public function getProduct($gid, $fields = []) {
		# Define the fields to retrieve
		
		if ( empty($fields) ) {
			$fields = $this->productFieldsArray();
		} else {
			$fields[] = 'id';
			$fields = array_values(array_unique($fields));
		}

		# Add the ID to the Product field
		$fields['__args'] = true;

		# Define query arguments (variable types)
		$arguments = [
			'id' => ['id' => 'ID!']
		];

		# Define variables (actual values)
		$variables = [
			'id' => $gid
		];

		$result = $this->callGraph(
			"getProduct",
			$this->queryGetItems('product', $fields, [$arguments]),
			$variables
		);

		return $result;
	}

	# Get all the variants of a product
	public function getProductVariants($product_id, $fields = [], $params = [], $limit = 30) {
		# Define the fields to retrieve
		if (empty($fields)) {
			$fields = $this->variantFieldsArray();
		} else {
			$fields[] = 'id';
			$fields = array_values(array_unique($fields));
		}

		# Define the query structure with variants connection
		$fields = [
			'nodes' => $fields
		];

		# Define query arguments (variable types)
		$arguments = [
			'first' => ['first' => 'Int!'],
			'query' => ['query' => 'String!'],
			'sortKey' => ['sortKey' => 'ProductVariantSortKeys!']
		];

		# Define variables (actual values)
		$variables = [
			'first' => min($limit, 250), // Shopify typically limits to 250 items per request
			'query' => "product_id:" . $product_id,
			'sortKey' => 'POSITION'
		];

		$result = $this->callGraph(
			"getProductVariants",
			$this->queryGetItems('productVariants', $fields, [$arguments]),
			$variables
		);

		return $result;
	}

	public function updateProduct($gid, $data) {
		# Convert data from REST format to GraphQL format
		$input = $this->convertProductToGraph($data);
		
		# Add the ID to the input
		$input['id'] = $gid;

		# Define query arguments (variable types)
		$arguments = [
			'input' => ['input' => 'ProductInput!']
		];

		# Define variables (actual values)
		$variables = [
			'input' => $input
		];

		# Define the fields to retrieve
		$fields = [
			'product' => $this->productFieldsArray()
		];

		# Call the productUpdate mutation
		$result = $this->callGraph(
			"updateProduct",
			$this->queryMutateItem('productUpdate', $fields, [$arguments]),
			$variables
		);

		# Return the updated product
		return $result['product'] ?? [];
	}

	
	/////////////// PRODUCTS, PAGES, COLLECTIONS, ARTICLES //////////////////

	/**
	 * Low-level API call to get items
	 * Currently only supports products, other item types use REST API
	 */

	public function getItemsRestCompatible($item_type_shopify, $since_id = null, $max_items = 1, $published = "any", $fields = [], $params = [], $name = "") {
		
		if ($item_type_shopify !== "products") {
			return [];
		}

		$items = [];
		$limit = 100; // Conservative default
		if (!$max_items) {
			$max_items = 100000;
		}

		if ( empty($fields) ) {
			$fields = $this->productFieldsArray();
		} else {
			$fields[] = 'id';
			$fields = array_values(array_unique($fields));
		}

		# Just include the nodes - pageInfo will be added automatically by queryGetItems
		$item_fields = [
			'__args' => true,
			'nodes' => $fields
		];
		
		$this->ShopifyApi->resetCursors();
		
		$page = 0;
		

		while ($page == 0 || ($this->ShopifyApi->cursorNext && count($items) < $max_items)) {
			
			$page++;

			$remaining = $max_items - count($items);
			$maxLimit = min($remaining, $limit);

			# ENABLES PAGINATION
			if (!empty($params['before'])) {				
				$this->ShopifyApi->cursorNext = $params['before'];	
				$productArgs = [
					'productsLimit' => ['last' => 'Int!']
				];				
			} else {			
				$this->ShopifyApi->cursorNext = $params['after'] ?? $this->ShopifyApi->cursorNext;
				$productArgs = [
					'productsLimit' => ['first' => 'Int!']
				];
			}

			$variables['productsLimit'] = $maxLimit;

			if ($this->ShopifyApi->cursorNext && !empty($params['before'])) {
				$productArgs['directionCursor'] = ['before' => 'String'];
				unset($params['before']);
			} else if ($this->ShopifyApi->cursorNext) {
				$productArgs['directionCursor'] = ['after' => 'String'];
				unset($params['after']);
			} else {
				# First request needs query parameters
				$queryConditions = [];
				
				# Always include status condition
				$status = $this->convertPublishedStatusToStatus($published);
				if (!empty($status)) {
					$queryConditions[] = "(status:" . $status . ")";
				}
				
				if ($since_id) {
					$queryConditions[] = "(id:>" . $since_id . ")";
				}
				
				# Add handle filter if provided
				if (!empty($params['handle'])) {
					$queryConditions[] = "(handle:" . $params['handle'] . ")";
				}

				# Add collection filter if provided
				if (!empty($params['collection_id'])) {
					$queryConditions[] = "(collection_id:" . $params['collection_id'] . ")";
				}

				if (!empty($queryConditions)) {
					$productArgs['queryFilter'] = ['query' => 'String!'];
				}

				# Add sort parameters only on first request and only if order is specified
				if (!empty($params['order'])) {
					$productArgs['sortKey'] = ['sortKey' => 'ProductSortKeys!'];
					$productArgs['reverse'] = ['reverse' => 'Boolean!'];
				} else {
					# If order is empty - force 'id asc'
					$productArgs['sortKey'] = ['sortKey' => 'ProductSortKeys!'];
					$productArgs['reverse'] = ['reverse' => 'Boolean!'];
				}
			}

			# Images
			if (!empty($fields['images'])) {
				$imagesArgs = [
					'imagesFirst' => ['first' => 'Int!']
				];
				$variables['imagesFirst'] = $params['images_limit'] ?? 250;
			} else {
				$imagesArgs = [];
			}

			# Options
			if (!empty($fields['options'])) {
				$optionArgs = [
					'optionsFirst' => ['first' => 'Int!']
				];
				$variables['optionsFirst'] = $params['options_limit'] ?? 10;
			} else {
				$optionArgs = [];
			}
			
			# Variants
			if (!empty($fields['variants'])) {
				$variantArgs = [
					'variantsFirst' => ['first' => 'Int!']
				];
				$variables['variantsFirst'] = $params['variants_limit'] ?? 10;
			} else {
				$variantArgs = [];
			}
			
			# Pagination
			if ($this->ShopifyApi->cursorNext) {
				$variables['directionCursor'] = $this->ShopifyApi->cursorNext;
			}
			
			# Query conditions
			if (!empty($queryConditions)) {
				$variables['queryFilter'] = implode(" AND ", $queryConditions);
			}


			# Sorting
			if (!empty($params['order'])) {
				$sortParts = explode(' ', $params['order']);
				$variables['sortKey'] = strtoupper($this->snakeToCamel($sortParts[0]));
				$variables['reverse'] = isset($sortParts[1]) && strtolower($sortParts[1]) === 'desc';
			} else {
				$variables['sortKey'] = "ID";
				$variables['reverse'] = false;
			}

	
			$result = $this->callGraph(
				"getItemsRestCompatible",
				$this->queryGetItems('products', $item_fields, [$productArgs, $imagesArgs, $optionArgs, $variantArgs]),
				$variables,
				false
			);
			
			if (empty($result['nodes'])) {
				break;
			}

			$items = array_merge($items, $result['nodes']);
		
		}

		return array_slice($items, 0, $max_items);
	}
	

    /////////////////// FILE OPERATIONS ///////////////////

    public function mediaImageFieldsArray() {
		
		return [
			'id',
			'createdAt',
			'updatedAt',
			'fileStatus',
			'image' => [
				'id',
				'width',
				'height',
				'src',
				'altText'
			]
		];

	}

    /**
     * Wait for an image to be ready
     */
    public function waitUntilImageReady($gid, $sleep = 2) {
        $sleep_max = 10;
        
        while ($sleep_max > 0) {
            
			sleep($sleep);
            
			$sleep_max--;
			
            $result = $this->getImageById($gid);
		
            if (!empty($result['fileStatus'])) {
                if ($result['fileStatus'] === 'FAILED') {
                    return false;
                } elseif ($result['fileStatus'] === 'PROCESSING') {
                    continue;
                } elseif ($result['fileStatus'] === 'READY') {
					if ( empty($result['image']) ) {
						continue; // This can happen with UPDATES since it already exists but its not done updating
					} else {
						return $result;
					}
                }
            }

            return false;
        }

        return false;
    }

	/**
     * Get a list of images based on specified criteria
   
     */
    public function getImages($limit = 10, $size = 40000, $cursor = [], $filename = '', $reverse = false, $used_in = '') {
        # Define the fields to retrieve
        $fields = [
            'nodes' => [
                '... on MediaImage' => $this->mediaImageFieldsArray()
            ]
        ];
        
		if (!empty($cursor['before'])) {
			$arguments['imageLimit'] = ['last' => 'Int!'];
        } else {
	        $arguments['imageLimit'] = ['first' => 'Int!'];
        }

		$variables['imageLimit'] = min($limit, 250);
		
		if (!empty($cursor['before'])) {
			
            $arguments['before'] = ['before' => 'String'];
			$variables['before'] = $cursor['before'];

        } else if (!empty($cursor['after'])) {

    		$arguments['after'] = ['after' => 'String'];
			$variables['after'] = $cursor['after'];

        } else {
		
			$arguments['query'] = ['query' => 'String!'];

			# Build query conditions
			$queryConditions = ["(media_type:IMAGE)", "(status:READY)"];
			
			if ($size > 0) {
				$queryConditions[] = "(original_upload_size:>$size)";
			}
			
			if ($filename) {
				$queryConditions[] = "(filename:$filename)";
			}
			
			if ($used_in) {
				$queryConditions[] = "(used_in:'$used_in')";
			}

			# Define variables (actual values)
			$variables['query'] = implode(" AND ", $queryConditions);

		}
    
        $result = $this->callGraph(
            "getImages",
            $this->queryGetItems('files', $fields, [$arguments]),
            $variables
        );

        return $result['nodes'] ?? [];
    }

    /**
     * Get a file by its GraphQL ID
     * @param string $gid File GraphQL ID
     * @return array|null File data or null if not found
     */
    public function getImageById($gid) {
        # Define the fields to retrieve
        $fields = [
            '... on MediaImage' => $this->mediaImageFieldsArray()
        ];

        # Define query arguments (variable types)
        $arguments = [
            'id' => ['id' => 'ID!']
        ];

        # Define variables (actual values)
        $variables = [
            'id' => $gid
        ];

        $result = $this->callGraph(
            "getFileById",
            $this->queryGetItems('node', $fields, [$arguments]),
            $variables
        );

        return $result;
    }

    /**
     * Get image by filename
     * @param string $filename Image filename to search for
     */
    public function getImageByName($filename, $used_in = '') {
        # Define the fields to retrieve
        $fields = [
            'nodes' => [
                '... on MediaImage' => $this->mediaImageFieldsArray()
            ]
        ];

        # Define query arguments (variable types)
        $arguments = [
            'query' => ['query' => 'String!'],
            'first' => ['first' => 'Int!']
        ];
        
		$queryConditions = ["(media_type:IMAGE)", "(status:READY)"];
				
		$queryConditions[] = "(filename:$filename)";
		
		if ($used_in) {
			$queryConditions[] = "(used_in:'$used_in')";
		}
		
        # Define variables (actual values)
        $variables = [
            'query' => implode(" AND ", $queryConditions),
            'first' => 200
        ];

        $result = $this->getAllItems(
            "getImageByName",
            $this->queryGetItems('files', $fields, [$arguments]),
            $variables
        );

        # Search for the image with the exact filename
        foreach ($result as $item) {
            if (!empty($item['image']['src']) && getFilename($item['image']['src']) === $filename) {
                return $item;
            }
        }

        return [];
    }


    /**
     * Create a new image    
     */
    public function addImage($url, $alt = "") {
        # Define the fields to retrieve
        $fields = [
            'files' => [
                '... on MediaImage' => $this->mediaImageFieldsArray()
            ]
        ];

        # Define query arguments (variable types)
        $arguments = [
            'files' => ['files' => '[FileCreateInput!]!']
        ];

        # Define variables (actual values)
        $variables = [
            'files' => [
                [
                    'alt' => $alt,
                    'originalSource' => $url
                ]
            ]
        ];

        $result = $this->callGraph(
            "addImage",
            $this->queryMutateItem('fileCreate', $fields, [$arguments]),
            $variables
        );

        if (!empty($result['files'][0]['id'])) {
            return $this->waitUntilImageReady($result['files'][0]['id']);
        }

        return null;
    }

    /**
     * Update an existing image
     */
    public function updateImage($gid, $src = null, $alt = null) {
        # Define the fields to retrieve
        $fields = [
            'files' => [
                '... on MediaImage' => $this->mediaImageFieldsArray()
            ]
        ];

        # Define query arguments (variable types)
        $arguments = [
            'files' => ['files' => '[FileUpdateInput!]!']
        ];

        # Build update input, only including non-null fields
        $updateInput = ['id' => $gid];
        if ($src !== null) {
            $updateInput['previewImageSource'] = $src;
        }
        if ($alt !== null) {
            $updateInput['alt'] = $alt;
        }

        # Define variables (actual values)
        $variables = [
            'files' => [$updateInput]
        ];

        $result = $this->callGraph(
            "updateImage",
            $this->queryMutateItem('fileUpdate', $fields, [$arguments]),
            $variables
        );

        if (!empty($result['files'][0]['id'])) {
            if ($src !== null) {
                return $this->waitUntilImageReady($result['files'][0]['id']);
            } else {
                return $result['files'][0];
            }
        }

        return null;
    }

	/////////////////// QUERIES ///////////////////

	public function cursorFieldsArray() {

		return [
			'hasNextPage',
			'hasPreviousPage',
			'startCursor',
			'endCursor'
		];

	}
	

    /**
     * Check if any field in the array has __args
     */
    private function hasArguments($fields) {
        if (!is_array($fields)) {
            return false;
        }

        foreach ($fields as $key => $value) {
            if ($key === '__args') {
                return true;
            }
            if (is_array($value) && $this->hasArguments($value)) {
                return true;
            }
        }
        return false;
    }

    /**
     * Build a GraphQL query
     */
    public function queryGetItems($queryType, $fields, $arguments = []) {
        # If no field has __args but we have arguments, add __args to root level
        if (!empty($arguments) && !$this->hasArguments($fields)) {
            $fields['__args'] = true;
        }
		
		# Include cursor fields for pagination only for supported query types
		$nonPaginatedTypes = [
			'productsCount',
			'shop',
			'appInstallation',
			'node',
			'availableLocales',
			'shopLocales'
		];
		
		if (!in_array($queryType, $nonPaginatedTypes)) {
			$fields['pageInfo'] = $this->cursorFieldsArray();
		}

        return $this->buildGraphQLQuery($queryType, $fields, $arguments);
    }

    /**
     * Build a GraphQL mutation
     */
    public function queryMutateItem($mutationType, $fields, $arguments = []) {
        # For mutations, ALWAYS add __args to root level since the mutation operation takes the input
        $fields['__args'] = true;
        
        return $this->buildGraphQLQuery($mutationType, $fields, $arguments, true);
    }

    /**
     * Build a GraphQL query or mutation
     */
    private function buildGraphQLQuery($queryType, $fields, $arguments, $isMutation = false) {
        # Build the variables declaration
        $varDeclarations = [];
        $argStrings = [];
        
        # Process all argument levels recursively
        $this->processArguments($arguments, $varDeclarations, $argStrings);

        # Start building query
        $operationType = $isMutation ? "mutation" : "query";
        $query = empty($varDeclarations) ? 
            "$operationType {\n" : 
            "$operationType(" . implode(", ", $varDeclarations) . ") {\n";
        
        # Wrap fields with the root query type before processing
        $wrappedFields = [
            $queryType => $fields
        ];
        
        # Add the fields - pass full arguments array
        $query .= $this->buildFields($wrappedFields, $arguments);
        
        # Close the query
        $query .= "}\n";
        
        return $query;
    }

    /**
     * Recursively process arguments at all levels
     */
    private function processArguments($arguments, &$varDeclarations, &$argStrings, $level = 0) {
        if (empty($arguments)) {
            return;
        }

        # Process current level
        if (isset($arguments[$level]) && is_array($arguments[$level])) {
            foreach ($arguments[$level] as $varName => $argDef) {
                foreach ($argDef as $argName => $type) {
                    # Add variable declaration for all levels
                    $varDeclarations[] = "\$$varName: $type";
                    
                    # Only add argument strings for first level
                    if ($level === 0) {
                        $argStrings[] = "$argName: \$$varName";
                    }
                }
            }
        }
        
        # Process next level recursively
        if (isset($arguments[$level + 1])) {
            $this->processArguments($arguments, $varDeclarations, $argStrings, $level + 1);
        }
    }

    /**
     * Recursively build fields part of query
     */
    private function buildFields($fields, $arguments) {
        # Initialize empty string to store the generated GraphQL fields
        $result = "";

        # Iterate through each field in the current level
        foreach ($fields as $key => $value) {
			
            # Skip the __args flag as it's only used for argument presence detection
            if ($key === '__args') continue;
            
            # Check if the current field has nested fields (is an array)
            if (is_array($value)) {
                # Skip this field entirely if it requires arguments but none are provided
                if (isset($value['__args']) && empty($arguments[0]) && $key !== 'nodes') {
					
					/// exists but is empty
					if (isset($arguments[0])) {
						array_shift($arguments);
					}

                    continue;
                }
                
                # Initialize array to store argument strings for this field
                $argStrings = [];

                # If we have arguments for this level AND this field accepts arguments
                if (!empty($arguments) && isset($value['__args']) && $key !== 'nodes') {
					$args = array_shift($arguments);
                    # Process each argument variable at this level
                    foreach ($args as $varName => $argDef) {
                        # For each argument definition, create the argument string
                        foreach ($argDef as $argName => $type) {
                            $argStrings[] = "$argName: \$$varName";
                        }
                    }
                }
                
                # Add the field name to the query
                # If we have arguments, append them in parentheses
                $result .= "\t\t$key" . (!empty($argStrings) ? "(" . implode(", ", $argStrings) . ")" : "") . " {\n";
                
                # Recursively process nested fields
                $result .= $this->buildFields($value, $arguments);
                
                # Close the nested field block
                $result .= "\t\t}\n";
            } else {
                # For scalar fields (strings), just add them with proper indentation
                $result .= "\t\t$value\n";
            }
        }
        # Return the complete fields string for this level
        return $result;
    }

}

Hi Liam, the docs are not always accurate, for example it says to use “originalSource” when updating an image. This is not correct, the actual field to use is “previewImageSource”. Your staff was made aware of this already and apparently its nobodys job to update the docs.

Also, the links within the graph docs often take you to mapping type pages like these:

which I suppose make some sense technically, but as a developer, it feels overly complicated because you are pushed to follow a rabbit hole of links. And there is no actual usage of FileEdge… this is referring to ‘files { edges: {…’ right? So tbh i dont really care what a FileEdge is. In my code i dont even use the words ‘edges’ with File, I use this object:

‘nodes’ => [
‘… on MediaImage’ => $this->mediaImageFieldsArray()
]
So i don’t even understand why i’m not using edges, because ‘nodes’ alone works fine.

It makes learning Graphql for Shopify very difficult. Maybe better to just put everything on one doc page for Files, and explain everything on a single page, instead of linking to FileConnection, FileEdge, File yada… Make one big, long page that explains everything about using the Files endpoint.

1 Like

As @jason_engage mentions, the coexistence of ‘edges + cursor’ and ‘nodes + pageInfo’ is quite confusing, and in the docs it is not clear the final and practical differences between both. And if in the end the purpose of both is the same, then it is even more confusing. Apparently, ‘nodes’ are more direct and clean… then, why does Shopify maintain ‘edges’? Answering this question would be very illustrative for devs :slight_smile:

I guess that the response may be that ‘edges’ are there because bulk operations can’t use ‘nodes’, according to https://shopify.dev/docs/api/usage/bulk-operations/queries#operation-restrictions. Ok… but if this is right, wouldn’t it be better to work on the compatibility of bulk operations with ‘nodes’ in order to definitely discard confusing ‘edges’?

Right, it might have something to do with bulk operations. I haven’t even gone that far into the graph api. My apps are all designed to process individual items within a queue, so i stay away from bulk. Probably slower, but it provides the ability to troubleshoot and process errors more easily (and simply skip to the next item if necessary).

@jason_engage Regarding this point

The webhooks api results and the Graphql api results are not the same, this is also insane. Please do not change the webhooks API to graphql for the risk of collapsing everything.

Can you detail what you mean by not the same? Which Webhooks API’s are you referring to specificially?

For context: we probably use close to 60 mutations/queries, maybe a little more, built out over a few years, even though we started out with the Rest API only.

It certainly has quirks and different parts of the API are written by different teams at Shopify. That should be expected at this point, given Shopify’s size and that’s exactly where Graphql shines. Rest API simply wouldn’t enable the same pace of development and flexibility.

Of course that brings challenges in having cohesive and consistent developer experience across the different mutations, etc. And sometimes, some mutation design will make my blood boil, while others we’ll laugh it off with the team. But I still think Shopify made the right choice there, even if primarily driven by organizational needs. The technical parts is something they need to continually improve, as we’ve seen them do.

I’m willing to bet that if they were on the Rest API it would be even a bigger mess.

When we were migrating from the rest API, we found that separating application/domain code from Graphql by using interface abstractions helped maintain the guts of the application, even when the API changed. And this has happened quite a few times over the years. As it should - changes external to your application will happen and you should have a strategy for how your going to handle them.

Regarding tests, Shopify is probably the easiest platform to write integration tests for. Just create a development store, a private app and start writing integration tests, so when you change your application code, or your external dependency changes, you can quickly find out what broke and fix it. Yes it takes time. Yes, it’s worth it.

Most of this input applies to pretty much any application towards any external system, not just Shopify. What I find makes things a little extra challenging is Shopify’s pace of development and innovation - it’s a lot of work to keep up, but I prefer that than the alternative :blush:

I would really like a way to give input to Shopify on specific mutations/queries that would be really evaluated and considered. Mistakes and tight deadlines happen, but they need to take feedback seriously. Some mutations are atrocious and there’s no excuse :joy: I’m confident it would be the same if they were only using the Rest API - it’s not a technical problem, it’s an organizational one.

@eytan-shopify The webhooks api sends data in REST format. Is there something I’m not aware of here? My server processes 10 of them per second.

@Evaldas_Raisutis I’m not against moving to better technology. I do use a lot of third party APIs and Shopify’s graphql api is in a league of its own thats for sure.

Would be nice to see examples of how people structure their Graphql query builders.

For example I just built a pagination to get the next 20 results or the previous 20 results, and in the rest api its simple: Pass in the Previous cursor or the Next cursor with the limit argument.

Where as in graph you have to not only do that, but also switch the query from using ‘after:’ to ‘before:’ AND FROM ‘first:’ TO ‘last:’. Do you know what I mean? Double the complexity to simply paginate. Am I doing something wrong?

what tech stack do you use?

Are you trying to refer me to a particular api management system? If so just post it.

No, I am just curious what tech stack you’re using. For example, we’re developing in .NET and there are tools available like “Strawberry Shake” which handle graphql client generation for your queries, so you don’t have to manually type them and manage them: https://chillicream.com/docs/strawberryshake/v14/get-started/console

I would recommend investigating if there are similar tools in your tech stack to help reduce manual work needed to implement infrastructure code, like http client / service abstraction.

In relation to the pagination and switching “after” and “before” - that’s not something we encoutered as an issue when working with the graphql api - if we need to loop through something we’ll pass the cursor as NULL in the first iteraton of the loop and the update the cursor after each API call. The graphql query remains static besides the variables being passed.

For example, we’ll have a graphql query defined as a constant:

        const string queryOrderLines = @"
          query ($orderId: ID!, $count: Int!, $cursor: String) {
            content: order(id: $orderId) {
              id
              items: lineItems(first: $count, after: $cursor) {
                pageInfo {
                  hasNextPage
                  hasPreviousPage
                }
                edges {
                  cursor
                  node {
                    id
                    sku
                    quantity
                    customAttributes {
                      key
                      value
                    }
                    discountAllocations {
                      allocatedAmountSet {
                        presentmentMoney {
                          amount
                          currencyCode
                        }
                      }
                      discountApplication {
                        ... on DiscountCodeApplication {
                          code
                        }
                      }
                    }
                    product {
                      isGiftCard
                    }
                    variant {
                      barcode
                      price
                      compareAtPrice
                    }
                    originalUnitPriceSet {
                      presentmentMoney {
                        amount
                      }
                    }
                    totalDiscountSet {
                      presentmentMoney {
                        amount
                      }
                    }
                    originalTotalSet {
                      presentmentMoney {
                        amount
                      }
                    }
                    discountedTotalSet {
                      presentmentMoney {
                        amount
                      }
                    }
                  }
                }
              }
            }
          }";

and then call the API with variables:

        private async Task<ShopifyLineItemResponse> GetOrderlinesByShopifyOrderId(ShopName shopName, string shopifyId, CancellationToken cancellationToken)
        {
            var shopifyOrderLineResponse = new ShopifyLineItemResponse();

            string? cursor = null;
            bool hasNextPage;
            do
            {
                var getShopifyOrderLinesInput = new
                {
                    OrderId = shopifyId,
                    Count = linesPerPage,
                    Cursor = cursor
                };

                var orderLinesResponse = await graphqlClient.SendAsync(shopName.Value, queryOrderLines, getShopifyOrderLinesInput, cancellationToken);

                hasNextPage = orderLinesResponse.Data.Content.Items != null
                    && orderLinesResponse.Data.Content.Items.PageInfo != null
                    && orderLinesResponse.Data.Content.Items.PageInfo.HasNextPage;

                var lastEdge = orderLinesResponse.Data.Content.Items?.Edges.LastOrDefault();

                cursor = !string.IsNullOrEmpty(lastEdge?.Cursor)
                    ? lastEdge.Cursor
                    : null;

           // do something with each page items

            } while (hasNextPage && !string.IsNullOrEmpty(cursor));


            return shopifyOrderLineResponse;
        }

The above code can be reduced to ~5 lines of code when using graphql client generator like mentioned earlier.

Hi @Evaldas_Raisutis I appreciate the code snippets. What your showing me is extremely basic though. Its just very straightforward declaration of your query and filling in the variables and running on a loop until the cursors are exhausted.

This is not what im referring to. I want to see dynamic query builders that are reusable across different queries. If you look at my code in a post above, you will see that i am adding the fields in separate functions, and pulling them in dynamically and building the queries and arguments dynamically. Or as much as I can.

Writing queryOrderLines = “” is absolutely what Im trying to avoid, otherwise my app will have maybe 60 or 70 of these string constants, maybe even more, since the configuration of the arguments can be very different.

And for anyone paying attention to what I’m saying, is that the Graph API lacks symmetry, so its very difficult to write a dynamic query builder. My code does work, but it needs tiny hacks because of the a-symmetry of the api and little issues like switching from first to last and after to before.

And even when it works, the data that is pulled from the graph api is not the same as that available on the Rest. Sure its like 95% ok, but that last 5% that is missing can be super frustrating.

AND you have to parse the data IE. remove all the “EDGES” and “NODES” keys that are useless and normalize it to match the REST if you are not running your app on 100% graph.

They are entirely different platforms. Shopify should really think about this fact and be sympathetic to developers that built everything on REST.

1 Like

I think you will need to use GraphQL Fragments.

1 Like

@remy727 i will look into this, was not aware of it

Conditionals are definately interesting

# @include makes more sense here because you're optionally adding extra data
query ($includeDetails: Boolean!) {
  product {
    title
    description @include(if: $includeDetails)
    specifications @include(if: $includeDetails)
  }
}

# @skip makes more sense here because you're removing sensitive data
query ($isPublicView: Boolean!) {
  user {
    name
    email @skip(if: $isPublicView)
    phone @skip(if: $isPublicView)
  }
}

These concepts should be more easily found in the docs!

TIL: I didn’t know @include and @skip directives.
Thank you for sharing.

Wow, it works great.

2 Likes

@remy727 what’s the tool you are using in your screenshot? Looks handy