Dmitry Kurtsev
Posted on May 17, 2024
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
- Prepare a space on the stage
- Upload to the stage
- 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
Now let's create a development store through Shopify and run our app locally by filling out the prompts:
~/web cd ..
~ yarn dev
Press p
or visit the generated preview url:
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>
);
}
Add new route to the router:
# /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:
# /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:
Result in the console:
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:
- Prepare a space on the Shopify stage.
- Upload the file to the stage.
- 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"
}
]
}
]
}
}
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=" }
]
}
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/"
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
# /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:
# /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
:
# /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:
# /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:
# /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:
# /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
:
/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:
# /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
:
# /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
:
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
:
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
:
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:
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
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
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
Testing:
Shopify doesn't allow to upload any HTML files, but for experimental purposes let's try to do this:
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
- 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 instagedUploadsCreate
-
MOST IMPORTANT
contentType
field infileCreate
-
preview
field infileCreate
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
Posted on May 17, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.