Shopify - upload file to shop with Ruby on Rails
Tags: shopify, api, rails
Published: 2024-05-17 11:51:30

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](https://github.com/xao0isb/shopify-upload-file-to-shop-with-ruby-on-rails/tree/main). 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](https://community.shopify.com/c/partners-and-developers/ct-p/appdev) 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](https://shopify.dev/docs/api/shopify-cli) with [Shopify app Ruby template](https://github.com/Shopify/shopify-app-template-ruby?tab=readme-ov-file):

```bash
~ yarn create @shopify/app --template=ruby
~ cd web
~/web bundle
~/web rails db:create db:migrate
```

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

```bash
~/web cd ..
~ yarn dev
```

Press `p` or visit the generated preview url:

![Demo project after generating](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/tbbc4zvc6aluh7ntmhrw.png)

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`:

```react
// /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>
  );
}
```

Add new route to the router:

```ruby
# /web/config/routes.rb
scope path: :api, format: :json do
  resources :files, only: :create
end
```

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

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

  def create
    pp params[:file]
  end
end
```

Let's test it out!

Upload test file to the form and submit:

![Uploaded test file to form](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/e2t2vd5u2zum07ejylpr.png)

Result in the console:

![Result of submitting test file](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/8zpsqfshlczewctqc3xn.png)

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`](https://shopify.dev/docs/api/admin-graphql/2024-04/mutations/stageduploadscreate).

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:

```json
{
  "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"
          }
        ]
      }
    ]
  }
}
```

### 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:

```json
{
  "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=" }
  ]
}
```

An example of the POST request for that staged target:

```bash
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/"
```

### 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`](https://shopify.dev/docs/api/admin-graphql/2023-07/mutations/fileCreate). 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:

```bash
bundle add sidekiq
bundle
```

```ruby
# /web/config/application.rb
config.active_job.queue_adapter = :sidekiq
```

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

```ruby
# /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

```

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

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

  def perform(file)
    
  end
end
```

#### 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:

```ruby
# /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
```

Now create the `stagedUploadsCreate` mutation:

```ruby
# /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
```

And `fileCreate` mutation:

```ruby
# /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
```

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`:

```toml
/shopify.app.toml
scopes = "write_products, write_files"
```

#### 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:

```ruby
# /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
```

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

```ruby
# /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
```

And of course every used method. `stage_file`:

```ruby
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
```

`upload_to_stage`:

```ruby
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
```

`upload_from_stage_to_shop`:

```ruby
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
```

#### Testing

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

![Uploaded test file in the shop files panel](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/pddzww5zs5iyw44n3hd6.png)

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:

```ruby
# /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
```

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

```ruby
# /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
```

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

```ruby
# /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
```

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bgcyvkc9o5y785w2p9du.png)

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](https://help.shopify.com/en/manual/shopify-admin/productivity-tools/file-uploads#file-requirements).

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`](https://shopify.dev/docs/api/admin-graphql/2024-04/mutations/stagedUploadsCreate) and [`fileCreate`](https://shopify.dev/docs/api/admin-graphql/2023-07/mutations/fileCreate) mutations. Especially:

- **MOST IMPORTANT** [`resource`](https://shopify.dev/docs/api/admin-graphql/2024-04/enums/StagedUploadTargetGenerateUploadResource) field in `stagedUploadsCreate`
- **MOST IMPORTANT** [`contentType`](https://shopify.dev/docs/api/admin-graphql/2023-07/enums/FileContentType) field in `fileCreate`
- [`preview`](https://shopify.dev/docs/api/admin-graphql/2023-07/objects/MediaPreviewImage) 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