DEV Community

Dmitry Kurtsev
Dmitry Kurtsev

Posted on • Edited on

Shopify - upload file to shop with Ruby on Rails

Shopify provides many APIs for developers. One of them is Admin API which gives us access to add our own features to user experience.

In this post we are going to develop "upload file to shop" feature using Ruby on Rails. You can find the entire source code here - https://github.com/xao0isb/shopify-upload-file-to-shop-with-ruby-on-rails. Feel free to clone the repository and run the code yourself.

For those who want to just copy the code, close task and go to a bar with friends (I'm not judging at all :)) I left the table of contents:

  • Prelude
  • Demo stand
  • Flow and logic
    1. Prepare a space on the stage
    2. Upload to the stage
    3. Upload the file from the stage to the shop
  • Implementation
  • File limitations
  • Images, Videos, 3D Models etc.

Prelude

Unfortunately sometimes Shopify API documentation is incomplete or even there's none at all. And all you have is the source code, maybe a few Shopify community questions or tutorials for different languages. I feel you, and that's why I made this guide.

This guide covers flow and logic behind the feature so you can easily replace Ruby on Rails with any other language or framework that is used in your project.

Demo stand

In real-world applications you can create a file to upload in code, download from external source or receive it from the frontend.

For simplicity let's choose the last option and build a demo stand with a simple form in which we can upload a file and submit it to our backend at the url /api/files.

I am not going to dive into details because this is not the topic of the article but I will leave links to documentations or guides.

First let's generate our demo project using the Shopify CLI with Shopify app Ruby template:

~ yarn create @shopify/app --template=ruby
~ cd web
~/web bundle
~/web rails db:create db:migrate
Enter fullscreen mode Exit fullscreen mode

Now let's create a development store through Shopify and run our app locally by filling out the prompts:

~/web cd ..
~ yarn dev
Enter fullscreen mode Exit fullscreen mode

Press p or visit the generated preview url:

Demo project after generating

Let's replace default page with a simple form in which we can upload a file and submit it to our backend at url /api/files:

// /web/frontend/pages/index.jsx
export default function HomePage() {
  return (
    <form enctype="multipart/form-data" method="post" action="/api/files/upload">
      <input type="file" name="file" />
      <input type="submit" value="Upload" />
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Add new route to the router:

# /web/config/routes.rb
scope path: :api, format: :json do
  resources :files, only: :create
end
Enter fullscreen mode Exit fullscreen mode

Create the files controller with the create action in which we are printing received file from the frontend:

# /web/app/controllers/files_controller.rb
class FilesController < ApplicationController
  skip_before_action :verify_authenticity_token

  def create
    pp params[:file]
  end
end
Enter fullscreen mode Exit fullscreen mode

Let's test it out!

Upload test file to the form and submit:

Uploaded test file to form

Result in the console:

Result of submitting test file

Great! The demo stand is ready and I suggest move on to the most interesting part.

Flow and logic

The logic behind uploading a file to Shopify shop is not that easy but pretty simple at the same time:

  1. Prepare a space on the Shopify stage.
  2. Upload the file to the stage.
  3. Tell Shopify to upload the file from the stage to the shop.

Let's break it down step by step.

Note. Uploading only possible with the GraphQL Admin API and not with the REST version. In this article I will use the Shopify GraphQL Admin API 2024-04. The functionality in other versions may differ.

1. Prepare a space on the stage

GraphQL Admin API provides us with stagedUploadsCreate mutation.

To mutation we should pass the file parameters as an argument. As soon as we have successfully sent mutation Shopify prepares the space at their stage and mutation returns staged targets as a response. Every stage target has such fields:

  • url - the url of prepared space on the Shopify stage in which we will upload our file.
  • parameters - parameters needed to authenticate a request to upload the file to the stage.
  • resourceUrl - the original source of future uploaded file on stage.

An example of the response from documentation:

{
  "stagedUploadsCreate": {
    "stagedTargets": [
      {
        "url": "https://snowdevil.myshopify.com/admin/tmp/files",
        "resourceUrl": "https://snowdevil.myshopify.com/tmp/26371970/products/dff10ea1-b900-45ad-9e4e-e8ba28b2e88d/image1.png",
        "parameters": [
          {
            "name": "filename",
            "value": "image1.png"
          },
          {
            "name": "mime_type",
            "value": "image/png"
          },
          {
            "name": "key",
            "value": "tmp/26371970/products/dff10ea1-b900-45ad-9e4e-e8ba28b2e88d/image1.png"
          }
        ]
      },
      {
        "url": "http://upload.example.com/target",
        "resourceUrl": "http://upload.example.com/target?external_video_id=25",
        "parameters": [
          {
            "name": "GoogleAccessId",
            "value": "video-development@video-production123.iam.gserviceaccount.com"
          },
          {
            "name": "key",
            "value": "dev/o/v/video.mp4"
          },
          {
            "name": "policy",
            "value": "abc123"
          },
          {
            "name": "signature",
            "value": "abc123"
          }
        ]
      },
      {
        "url": "http://upload.example.com/target/dev/o/v/3d_model.glb?external_model3d_id=25",
        "resourceUrl": "http://upload.example.com/target/dev/o/v/3d_model.glb?external_model3d_id=25",
        "parameters": [
          {
            "name": "GoogleAccessId",
            "value": "video-development@video-production123.iam.gserviceaccount.com"
          },
          {
            "name": "key",
            "value": "dev/o/v/3d_model.glb"
          },
          {
            "name": "policy",
            "value": "abc123"
          },
          {
            "name": "signature",
            "value": "abc123"
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

2. Upload to the stage

Uploading to stage can be done with simple POST request. In order for Shopify to authenticates us we should pass the authentication parameters which we received with every staged target.

An example of the staged target:

{
  "url": "https://shopify-staged-uploads.storage.googleapis.com/",
  "resourceUrl": "https://shopify-staged-uploads.storage.googleapis.com/tmp/70193086714/files/637bdaf3-93ad-4be7-8d4e-4ea4c1be3945/demo_file.txt",
  "parameters": [
    { "name": "Content-Type", "value": "text/plain"},
    { "name": "success_action_status", "value": "201" },
    { "name": "acl", "value": "private"},
    { "name": "key", "value": "tmp/70193086714/files/637bdaf3-93ad-4be7-8d4e-4ea4c1be3945/demo_file.txt" },
    { "name": "x-goog-date", "value": "20240517T093600Z" },
    { "name": "x-goog-credential", "value": "merchant-assets@shopify-tiers.iam.gserviceaccount.com/20240517/auto/storage/goog4_request" },
    { "name": "x-goog-algorithm", "value": "GOOG4-RSA-SHA256" },
    { "name": "x-goog-signature", "value": "47385aa821...958d6" },
    { "name": "policy", "value": "eyJjb25kaX...aIn0=" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

An example of the POST request for that staged target:

curl -v \
  -F "Content-Type=text/plain" \
  -F "success_action_status=201" \
  -F "acl=private" \
  -F "key=tmp/70193086714/files/637bdaf3-93ad-4be7-8d4e-4ea4c1be3945/demo_file.txt" \
  -F "x-goog-date=20240517T093600Z" \
  -F "x-goog-credential=merchant-assets@shopify-tiers.iam.gserviceaccount.com/20240517/auto/storage/goog4_request" \
  -F "x-goog-algorithm=GOOG4-RSA-SHA256" \
  -F "x-goog-signature=47385aa821...958d6" \
  -F "policy=eyJjb25kaX...aIn0=" \
  -F "file=@/home/xao0isb/Downloads/demo_file.txt" \
   "https://shopify-staged-uploads.storage.googleapis.com/"
Enter fullscreen mode Exit fullscreen mode

3. Upload the file from the stage to the shop

And now we can finally tell Shopify to upload a file from the stage to the shop - fileCreate mutation. As an argument we pass the files. Each file includes the originalSource field which is the staged target's resourceUrl.

As I said the flow is not that easy but is pretty simple at the same time. Now let's move on to implementing our logic!

Implementation

Preparation

I'm going to implement the operation as a job and use sidekiq for this purpose:

bundle add sidekiq
bundle
Enter fullscreen mode Exit fullscreen mode
# /web/config/application.rb
config.active_job.queue_adapter = :sidekiq
Enter fullscreen mode Exit fullscreen mode

Change the code of the FilesController create action so that Shopify::File::UploadJob is called with the received file:

# /web/app/controllers/files_controller.rb
class FilesController < ApplicationController
  skip_before_action :verify_authenticity_token

  def create
    shop = Shop.find_by(shopify_domain: current_shopify_domain)

    Shopify::File::UploadJob.perform_later(params[:file], shop: Shop.last)
  end
end

Enter fullscreen mode Exit fullscreen mode

Let's create the Shopify::File::UploadJob:

# /web/jobs/shopify/file/upload.rb
class Shopify::File::UploadJob < ApplicationJob
  queue_as :default

  def perform(file)

  end
end
Enter fullscreen mode Exit fullscreen mode

Mutations

I want to make the way of interacting with Shopify GraphQL mutations more convenient. So let's create the ShopifyGraphql::Mutation module. In this module let's create a base mutation class with the send_mutation method:

# /web/lib/shopify_graphql/mutation/base_mutation.rb

class ShopifyGraphql::Mutation::BaseMutation
  class << self
    def send_mutation(shop:, mutation:, variables:)
      shop.with_shopify_session do |session|
        client = ShopifyAPI::Clients::Graphql::Admin.new(session:)

        client.query(query: mutation, variables:)
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Now create the stagedUploadsCreate mutation:

# /web.lib/shopify_graphql/mutation/staged_uploads/create.rb

class ShopifyGraphql::Mutation::StagedUploads::Create < ShopifyGraphql::Mutation::BaseMutation
  MUTATION = <<~MUTATION
    mutation stagedUploadsCreate($input: [StagedUploadInput!]!) {
      stagedUploadsCreate(input: $input) {
        stagedTargets {
          url
          resourceUrl
          parameters {
            name
            value
          }
        }
      }
    }
  MUTATION

  class << self
    def call(shop:, variables:)
      response = send_mutation(shop:, mutation: MUTATION, variables:)

      response.body["data"]
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

And fileCreate mutation:

# /web/lib/shopify_graphql/mutation/file_create.rb

class ShopifyGraphql::Mutation::File::Create < ShopifyGraphql::Mutation::BaseMutation
  MUTATION = <<~MUTATION
    mutation fileCreate($files: [FileCreateInput!]!) {
      fileCreate(files: $files) {
        files {
          id
          fileStatus
          createdAt
          updatedAt
        }
      }
    }
  MUTATION

  class << self
    def call(shop:, variables:)
      response = send_mutation(shop:, mutation: MUTATION, variables:)

      response.body["data"]
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

The fileCreate mutation also requires the write_files, write_themes or write_images access scope so don't forget to add one of them to scopes in your app.toml config. I will add write_files:

/shopify.app.toml
scopes = "write_products, write_files"
Enter fullscreen mode Exit fullscreen mode

Form data

Second step in logic is to send the file to stage. We should do this by using the form and setting the file and authentication parameters as data. Let's create a simple FormData class that will help us with this:

# /web/lib/form_data.rb

class FormData
  def initialize(action:, data:)
    @uri = URI(action)
    @data = data
  end

  def submit
    request = Net::HTTP::Post.new(@uri)
    request.set_form(@data, "multipart/form-data")

    Net::HTTP.start(@uri.hostname, @uri.port, use_ssl: true) do |http|
      http.request(request)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Now when everything is ready let's finally implement our Shopify::File::UploadJob:

# /web/app/jobs/shopify/file/upload_job.rb

class Shopify::File::UploadJob < ApplicationJob
  queue_as :default

  def perform(file, shop:)
    staged_file = stage_file(file, shop:)

    upload_to_stage(file, stage_url: staged_file[:url], authentication_parameters: staged_file[:parameters])

    upload_from_stage_to_shop(resource_url: staged_file[:resourceUrl], shop:)
  end
end
Enter fullscreen mode Exit fullscreen mode

And of course every used method. stage_file:

def stage_file(file, shop:)
  variables = {
    input: [
      {
        filename: file.original_filename,
        mimeType: file.content_type,
        resource: "FILE",
        httpMethod: "POST"
      }
    ]
  }

  mutation_result = ShopifyGraphql::Mutation::StagedUploads::Create.call(shop:, variables:)
  staged_target = mutation_result["stagedUploadsCreate"]["stagedTargets"].first

  staged_target.transform_keys(&:to_sym)
end
Enter fullscreen mode Exit fullscreen mode

upload_to_stage:

def upload_to_stage(file, stage_url:, authentication_parameters:)
  form_data = [["file", file]]

  authentication_parameters.each do |parameter|
    form_data.prepend([parameter["name"], parameter["value"]])
  end

  FormData.new(action: stage_url, data: form_data).submit
end
Enter fullscreen mode Exit fullscreen mode

upload_from_stage_to_shop:

def upload_from_stage_to_shop(resource_url:, shop:)
  variables = {
    files: {
      originalSource: resource_url,
      contentType: "FILE"
    }
  }

  mutation_result = ShopifyGraphql::Mutation::File::Create.call(shop:, variables:)
  created_file = mutation_result["fileCreate"]["files"].first

  created_file.transform_keys(&:to_sym)
end
Enter fullscreen mode Exit fullscreen mode

Testing

Upload the test file and submit. Result in the shop files panel:

Uploaded test file in the shop files panel

Great! Everything is working! But now our code implements an ideal flow and in the real world experience of course everything is not that perfect. So let's add handling of future potential errors.

Errors handling

The best practice would be not to use StandardError and create a hierarchy of custom errors but this is not the topic of the article so I'll leave it up to readers ;)

For ShopifyGraphql::Mutation::StagedUploads::Create mutation simply just add the userErrors object in GraphQL request and handle them if they appear:

# /web/lib/shopify_graphql/mutation/staged_uploads/create.rb

class ShopifyGraphql::Mutation::StagedUploads::Create < ShopifyGraphql::Mutation::BaseMutation
  MUTATION = <<~MUTATION
    mutation stagedUploadsCreate($input: [StagedUploadInput!]!) {
      stagedUploadsCreate(input: $input) {
        stagedTargets {
          ...
        }

        userErrors {
          field
          message
        }
      }
    }
  MUTATION

  class << self
    def call(shop:, variables:)
      response = send_mutation(shop:, mutation: MUTATION, variables:)
      data = response.body["data"]
      result_data = data["stagedUploadsCreate"]

      raise StandardError, result_data["userErrors"] if result_data["userErrors"].any?

      data
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Do the same for ShopifyGraphql::Mutation::File::Create mutation but also add the fileErrors object in files and handle them the same way:

# /web/lib/shopify_graphql/mutation/file/create.rb

class ShopifyGraphql::Mutation::File::Create < ShopifyGraphql::Mutation::BaseMutation
  MUTATION = <<~MUTATION
    mutation fileCreate($files: [FileCreateInput!]!) {
      fileCreate(files: $files) {
        files {
          ...
          fileErrors {
            code
            details
            message
          }
        }

        userErrors {
          field
          code
          message
        }
      }
    }
  MUTATION

  class << self
    def call(shop:, variables:)
      response = send_mutation(shop:, mutation: MUTATION, variables:)
      data = response.body["data"]
      result_data = data["fileCreate"]

      handle_errors!(result_data)

      data
    end

    private

    def handle_errors!(result_data)
      raise StandardError, result_data["userErrors"] if result_data["userErrors"].any?

      files = result_data["files"]
      files.each do |file|
        raise StandardError, file["fileErrors"] if file["fileErrors"].any?
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

For FormData we are going to raise error if response is not successful:

# /web/lib/form_data.rb

class FormData
  ...
  def submit
    ...
    Net::HTTP.start(@uri.hostname, @uri.port, use_ssl: true) do |http|
      response = http.request(request)

      raise StandardError, response unless successful_response?(response)
    end
  end

  private

  def successful_response?(response)
    code = response.code.to_i
    code.in?(200..299)
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing:

Shopify doesn't allow to upload any HTML files, but for experimental purposes let's try to do this:

Error that occurred when uploading the HTML file

Great! Errors handling works too!

You may ask: "wait, what other limitations does Shopify impose on uploaded files?" - let's dive into it.

File limitations

Shopify provides documentation on file limitations.

You can not upload any HTML files. Also they say that if you are on a trial plan then you can upload only:

  • JS
  • CSS
  • GIF
  • JPEG
  • PNG
  • JSON
  • CSV
  • PDF
  • WebP
  • HEIC

But I tried to upload XML and it worked perfectly. So I don't know about any other formats.

If you have any other information about file limitations, please share it in the comments! :)

Images, Videos, 3D models etc.

If you going to upload files other than just plain text then make sure to pass correct fields to stagedUploadsCreate and fileCreate mutations. Especially:

  • MOST IMPORTANT resource field in stagedUploadsCreate
  • MOST IMPORTANT contentType field in fileCreate
  • preview field in fileCreate if you want also to receive preview for image

Combinations of the first two are used for different file formats.

Conclusion

I was glad to share my personal experience through this article. Feel free to share your experience or opinion in the comments! And if you liked this post you can leave a reaction/like whatever :) Ciao!

Contacts: xao0isb@gmail.com

Top comments (0)