Using the GraphQL Admin API to Upload files
I found this awesome blog post about creating staged uploads with the GraphQL admin api. (it also has a github for reference). The React portion of the tutorial was great, but it assumed that the backend was node.js created by the Shopify CLI. I realized I had not yet used the GraphQL Admin API in my Rails app. So this tutorial will be building off of the Staged Uploads tutorial and explaining the Rails side of the equation.
Required Gems
This tutorial is assuming that you have a Shopify app built on Ruby on Rails using the shopify_app
gem.
The 3 gems you'll need:
gem 'shopify_app', '15.0.0'
gem 'graphql-client'
gem 'graphql'
You'll likely have the shopify_app
and the graphl-client
gem already in your gemfile. So the one you'll need to add is the graphl
gem. Here's the github for reference.
Once you add the graphl gem to your gem file and run a bundle install
you'll want to run the generator. Run the following command in your terminal.
$ rails generate graphql:install
This generator creates a bunch of files. We'll be looking at the app > controllers > graphql_controller
specifically. The first thing we want to do is make sure this controller is inheriting from the Authenticated controller rather than the regular Application controller.
We'll also want to uncomment the protect_from_forger line as we'll be accessing this controller from the front end.
class GraphqlController < ShopifyApp::AuthenticatedController
# If accessing from outside this domain, nullify the session
# This allows for outside API access while preventing CSRF attacks,
# but you'll have to authenticate your user separately
protect_from_forgery with: :null_session
def execute
...
end
...
end
Another thing to note is that a graphql route has been added to the config > routes
file. Note that this routes to the Graphql Controller and the execute method.
post "/graphql", to: "graphql#execute"
Create Shopify Graphql Schema
Next we'll need to create a Shopify graphql schema that corresponds with the version of the api you're using. The file gets created in the db folder db > shopify_graphql_schemas > 2021-07.json
(where the json file name corresponds to the api version you're using in your app). Here's the reference in the docs.
To create this schema run the following command in the terminal. Note that the Shop domain can be any shop - most likely the one you're testing on. If you're following convention the ACCESS_TOKEN is likely saved in your database in Shop.shop_token.
$ rake shopify_api:graphql:dump SHOP_DOMAIN=SHOP_NAME.myshopify.com ACCESS_TOKEN=shpat_xxxxx API_VERSION=2021-07
Required Scopes
In order to be able to access the files we'll need to make sure we have set the read_files, write_files
scopes. We set this in the config > initializers > shopify_app.rb
file
config.scope = "read_files, write_files"
Don't forget to restart your server for the initializer. Also, you'll need to uninstall and re-install the app to accept the new permissions.
The Execute Method
The generator created the execute method with the first 4 variables already build in and parsed the standard params coming into the app. The rest of the generator assumes the queries are coming from inside the app. This is where the graphql-client
gem comes in. You'd use that syntax to make a call outside of the Rails app. However, the shopify_api
gem is built upon that gem and you can use that to make the call.
First we create a client, then we parse the query. We then run the query method on the client with the parsed query and the variable (if applicable) as the parameters and then return the data as the result.
def execute
variables = prepare_variables(params[:variables])
query = params[:query]
operation_name = params[:operationName]
context = {
# Query context goes here, for example:
# current_user: current_user,
}
client = ShopifyAPI::GraphQL.client
incoming_query = ShopifyAPI::GraphQL.client.parse(query)
result = client.query(incoming_query, variables: variables).data
render json: result
rescue StandardError => e
raise e unless Rails.env.development?
handle_error_in_development(e)
end
Back to the Frontend
Here's the font upload component for the React portion of the app. I just want to point out a few things that differ from the original tutorial and call out a few things.
We're using @apollo/client
here rather the the apollo boost, and now gql and useMutation are apart of that node module.
Here we're uploading to the files rather than a collection.
In the original tutorial the actual queries were named. For example mutation generateStagedInput($input: [StagedUploadInput!]!)
. This seems to break the Rails queries, so I took them out and now it's just mutation ($input: [StagedUploadInput!]!)
.
Note, when calling our queries, we add an input object called variables for the query inputs. This corresponds with the variables variable in the controller and so it must be called variables.
import React, { useState } from "react";
import { DropZone, Thumbnail, Spinner } from "@shopify/polaris";
import { gql, useMutation } from '@apollo/client';
import { NoteMinor } from '@shopify/polaris-icons'
const STAGED_UPLOADS_CREATE = gql`
mutation ($input: [StagedUploadInput!]!) {
stagedUploadsCreate(input: $input) {
stagedTargets {
url
resourceUrl
parameters {
name
value
}
}
userErrors {
field
message
}
}
}
`;
const FILE_UPDATE = gql`
mutation ($files: [FileCreateInput!]!) {
fileCreate(files: $files) {
files {
fileStatus
}
userErrors {
field
message
}
}
}
`
function FontUpload(props) {
const [loading, setLoading] = useState(false);
const [generateStagedUploads] = useMutation(STAGED_UPLOADS_CREATE);
const [fileUpdate] = useMutation(FILE_UPDATE);
const handleDropZoneDrop = async ([file]) => {
setLoading(true)
try {
let { data } = await generateStagedUploads({
variables: {
"input": [
{
"fileSize": file.size.toString(),
"filename": file.name,
"httpMethod": "POST",
"mimeType": file.type,
"resource": "FILE"
}
]
}
})
const [{ url, parameters, resourceUrl }] = data.stagedUploadsCreate.stagedTargets
const formData = new FormData()
parameters.forEach(({ name, value }) => {
formData.append(name, value)
})
formData.append('file', file)
try {
const response = await fetch(url, {
headers: props.headers,
method: 'POST',
body: formData,
})
if (!response.ok) {
throw ('File could not be uploaded.')
}
const key = parameters.find(p => p.name === 'key')
let { newdata } = await fileUpdate({
variables: {
"files": [
{
"alt": "test",
"contentType": "FILE",
"originalSource": resourceUrl
}
]
}
})
if (newdata) {
throw ('Collection image could not be updated.')
}
} catch (err) {
props.setToastMessage(err)
}
setLoading(false)
} catch (e) {
// do something with the error here
console.log("ERROR", e)
}
}
return (
{loading ? : }
);
}
export default FontUpload;
How it works
The STAGED_UPLOADS_CREATE
query takes the file and saves it to a Shopify AWS bucket, the FILE_UPDATE
query saves the file to a specific shop via the AWS file url. This is a big πwinπ because it takes the file processing off our plate and according to the article is more performant.