Saturday, March 23, 2024

Copy Shopify Product Example

If you are working on a Shopify project, chances are you'll have multiple stores. One for development, one for pre-production and one for production or live which is accessible to the public. Because of this, you'ld probably want these stores to have the same products. Although there is an option to import/export products in Shopify admin, it does not copy over the metafields. And you're in deep equine effluence if rely heavily on metafields.

In this example, we are going to learn how to copy a product to another store using Shopify GraphQL. This example builds upon my previous blog, Shopify Bulk Query Example. I would recommend you read the previous blog if you want to know more about the cached products. I will only talk about how to copy a product to another store in this article. Okay, let's get to it.

Target Store

As mentioned above, we are building upon the previous example, Shopify Bulk Query Example. So we already have a Quickstart store in place. Let's create a target development store. On the Shopify partners page go to Stores -> Add store -> Create development store. Choose "Create a store to test and build", fill in the form as appropriate. Example Copy Product store:

After the target store has been created, it shouldn't have any products like below.

Custom App on Target Store

On the target store, go to Settings -> Apps and sales channels -> Develop apps. Allow custom app development and Create an app. Let's name it Product API. On the Configuration tab you must have the access scopes write_products and read_products. As of this writing, the webhook version is 2024-01. Install the app. On the API credentials tab, take note of your Admin API access token (you'll need to add this in the request header). Keep your access tokens secure. Only share them with developers that you trust to safely access your data. Take note of your API key and secret key as well. Our Copy Product store is now configured and ready to create products. For detailed steps on how to create a custom app, please take a look at my previous blog, Consuming Shopify Product API Example.

Config Changes

First off, we'll need to add the target store to our config file and update our code. Should be self explanatory based on the names. We have a source store and a target store. We copy the product from the source store (i.e. endpoint) over to the target store (i.e. targetEndpoint)

example-config.json


{
    "port": 0,
    "shopify": {
        "endpoint": "https://.myshopify.com/admin/api//graphql.json",
        "accessToken": "yourAccessToken",
        "targetEndpoint": "https://.myshopify.com/admin/api//graphql.json",
        "targetAccessToken": "targetAccessToken"
    }
}

config.go


// snipped...

type Shopify struct {
	Endpoint          string `json:"endpoint"`
	AccessToken       string `json:"accessToken"`
	TargetEndpoint    string `json:"targetEndpoint"`
	TargetAccessToken string `json:"targetAccessToken"`
}

// snipped...

Routing Addition

In main.go, we add the route to /copy-product which we will hit to trigger the copying of a product.

main.go


    // snipped...

    r.Get("/", router.GetStatus)

	r.Mount("/products", router.GetProducts(config))
	r.Mount("/cached-products", router.GetCachedProducts(config, products))
	r.Mount("/copy-product", router.CopyProduct(config, products))

	http.ListenAndServe(fmt.Sprintf(":%v", config.Port), r)
}

Copy Shopify Product Handler

In this part of the code, we pull the gid of the product that we are going to copy from the query parameter. We then find it if that product exists in the product cache. We call the CopyProduct function which returns an error if the copy was unsuccessful and then we send back a Copy product failed message to the caller in JSON format. For a successful copy, the source product information is returned.

products.go


// snipped...

func CopyProduct(config config.Config, products []service.Product) chi.Router {
	router := chi.NewRouter()

	router.Get("/", func(w http.ResponseWriter, r *http.Request) {

		gid := r.URL.Query().Get("gid")

		copyProduct := service.Product{
			ID:          "",
			Title:       "",
			Handle:      "",
			Vendor:      "",
			ProductType: "",
			Tags:        make([]string, 0),
			Metafields:  make([]service.Metafield, 0),
		}

		for _, product := range products {
			if product.ID == gid {
				fmt.Println("Found:", product.ID)
				copyProduct = product
				break
			}
		}

		jsonBytes := marshaller.Marshal(copyProduct)

		err := service.CopyProduct(config, copyProduct)
		if err != nil {
			jsonBytes = []byte("{ \"message\": \"Copy product failed\"}")
		}

		w.Header().Set(contentType, applicationJson)
		w.WriteHeader(200)
		w.Write(jsonBytes)
	})

	return router
}

Copy Shopify Product Crux

Nothing fancy. Just an update on the data model based on Shopify documentation. For more information, go to Shopify Docs.

bulk-query.go


// snipped...

type ProductCreate struct {
	Product Product `json:"product,omitempty"`
}

type Data struct {
	BulkOperationRunQuery BulkOperationRunQuery `json:"bulkOperationRunQuery,omitempty"`
	CurrentBulkOperation  BulkOperation         `json:"currentBulkOperation,omitempty"`
	ProductCreate         ProductCreate         `json:"productCreate,omitempty"`
}

// snipped...

This is where all the grunt work happens. The first lines of the copy product function will create the metafields portion of the GraphQL query. The metafields portion is then inserted to the productCreate GraphQL mutation query. For this example, we are going to assume all metafields are of type single_line_text_field. Because of this, the caveat is, this will not work if the metafield of the product you are going to copy is of a different type. The request is then sent to the target endpoint with the target accss token in the header. If all goes well, it will print the gid of the newly created product. Otherwise the error is passed to the caller to handle.

copy-product.go


package service

import (
	"fmt"
	"io"
	"net/http"
	"shopify-product-api/config"
	"shopify-product-api/marshaller"
	"strings"
)

func CopyProduct(config config.Config, product Product) error {
	// https://shopify.dev/docs/api/admin-graphql/2024-01/mutations/productCreate
	fmt.Println("++ copy product")

	metafieldStr := ""
	var sb strings.Builder

	for i := 0; i < len(product.Metafields); i++ {
		if i > 0 {
			sb.WriteString(",")
		}
		sb.WriteString("{")
		sb.WriteString(fmt.Sprintf(`namespace: "%s",`, product.Metafields[i].Namespace))
		sb.WriteString(fmt.Sprintf(`key: "%s",`, product.Metafields[i].Key))
		sb.WriteString(fmt.Sprintf(`value: "%s",`, product.Metafields[i].Value))
		sb.WriteString(fmt.Sprintf(`type: "%s"`, "single_line_text_field")) // assume all metafields are of this type
		sb.WriteString("}")
	}

	if len(product.Metafields) > 0 {
		metafieldStr = fmt.Sprintf(`metafields: [%s]`, sb.String())
	}

	productCreateGql := fmt.Sprintf(`
	mutation { 
		productCreate(
			input: {
				title: "%s",
				productType: "%s",
				vendor: "%s",
				tags: "%s",
				%s
			}
		) {
			product {
				id
			}
		}
	}
	`, product.Title, product.ProductType,
		product.Vendor, strings.Join(product.Tags, ","),
		metafieldStr)

	query := GqlQuery{
		Query: productCreateGql,
	}

	client := &http.Client{}

	responseBody, err := sendCopyRequest(client, query, config)
	if err != nil {
		return err
	}

	gqlResp := marshaller.Unmarshal[GqlResponse](responseBody)

	fmt.Println("New product copied: ", gqlResp.Data.ProductCreate.Product.ID)

	return nil
}

func sendCopyRequest(client *http.Client, query GqlQuery, config config.Config) ([]byte, error) {
	q := marshaller.Marshal(query)
	body := strings.NewReader(string(q))

	req, err := http.NewRequest(http.MethodPost, config.Shopify.TargetEndpoint, body)
	if err != nil {
		return nil, err
	}
	req.Header.Add(contentType, applicationJson)
	req.Header.Add("X-Shopify-Access-Token", config.Shopify.TargetAccessToken)

	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	fmt.Println("Response status:", resp.Status)

	return io.ReadAll(resp.Body)
}

Demonstration

I'm assuming you have read my previous blogs, Consuming Shopify Product API Example and Shopify Bulk Query Example. So you should be able to run the golang app and hit the endpoints with Postman. First, let's fetch the cached products and I've chosen gid://shopify/Product/8787070452004 as my source product like so.

Second, let's hit the copy product endpoint and provide the gid query parameter. A successful copy returns the source product information like so.

On the logs, we should see something like below.


Found: gid://shopify/Product/8787070452004
++ copy product
Response status: 200 OK
New product copied:  gid://shopify/Product/8343228219651
2024/03/16 22:35:45 "GET http://localhost:4000/copy-product?gid=gid://shopify/Product/8787070452004 HTTP/1.1" from [::1]:51397 - 200 283B in 22.7344888s

Before we can see the metafields on the Shopify admin product page, we need some bit of configuration for the metafields to show on the product page. Go to Settings -> Custom Data -> Products -> Metafields without a definition. Our namespace and key have been created. Add the definition (i.e. name, description and type). After saving, it will appear in the product page.

Finally, we can see our copied product along with the tags and metafields in all its glory.

Copy Shopify Product Wrap Up

There you have it. A way to copy a product from store to store in Shopify using Shopify GraphQL Admin API. This should form as a base for copying products between stores. Grab the full repo here, github.com/jpllosa/shopify-product-api/tree/copy-product.