Upload media with remix and graphql

Solved
EvilGranny
Shopify Partner
14 2 1

Hey, I'm trying to upload the media with graphql. I read all the docs and this article: 
https://shopify.dev/docs/apps/online-store/media/products#step-1-upload-media-to-shopify .

There's my file.server.js:

export const uploadFile = async (graphql) => {
  const result = await graphql(`
    mutation generateStagedUploads {
      stagedUploadsCreate(input: [
        {
          filename: "watches_comparison.mp4",
          mimeType: "video/mp4",
          resource: VIDEO,
          fileSize: "899765"
        },
        {
          filename: "another_watch.glb",
          mimeType: "model/gltf-binary",
          resource: MODEL_3D,
          fileSize: "456"
        }
      ])
      {
        stagedTargets {
          url
          resourceUrl
          parameters {
            name
            value
          }
        }
        userErrors {
          field, message
        }
      }
    }
  `);

  console.log('result', result);
  console.log('result.data', result.data);
  console.log('result.data?.stagedUploadsCreate', result.data?.stagedUploadsCreate);
  console.log('result.data?.stagedUploadsCreate.stagedTargets', result.data?.stagedUploadsCreate.stagedTargets);

  return result;
}

And I call it in the loader:

  const { admin } = await authenticate.admin(request);
  await uploadFile(admin.graphql)


This response returns right data, when i use it in Shopify GraphiQL App.
But when i do it on the server, i have an empty object in response. What am I doing wrong?



Accepted Solution (1)
EvilGranny
Shopify Partner
14 2 1

This is an accepted solution.

Ok, this code works. I hope it will be usefull:
// sopify.app.toml

scopes = "read_products,write_products,write_files,read_files,read_themes,write_themes"


// components/filesUploader.jsx

const handleDropZoneDrop = useCallback(async (_dropFiles, acceptedFiles, _rejectedFiles) => {
const formData = new FormData();

if (acceptedFiles.length) {
setFiles(acceptedFiles);
}

acceptedFiles.forEach((file) => {
formData.append('files', file)
});

const result = await fetch('/file', {
'method': 'POST',
headers: {
contentType: 'multipart/form-data',
},
body: formData,
});
}, []);

// routes/file.jsx

import { unstable_parseMultipartFormData, unstable_createMemoryUploadHandler } from "@remix-run/node";
import { authenticate } from "~/shopify.server";
import { uploadFile } from "~/models/file.server";

export const action = async ({request}) => {
const { admin } = await authenticate.admin(request);

const uploadHandler = unstable_createMemoryUploadHandler({
maxPartSize: 20_000_000,
});

const formData = await unstable_parseMultipartFormData(
request,
uploadHandler
);

const files = formData.getAll('files');

const result = await uploadFile(files, admin.graphql);

return {
data: result,
}
}

// files.server.js

import {fetch, FormData} from "@remix-run/node";

const prepareFiles = (files) => files.map((file) => ({
filename: file.name,
mimeType: file.type,
resource: file.type.includes('image') ? 'IMAGE' : 'FILE',
fileSize: file.size.toString(),
httpMethod: 'POST',
}));

const prepareFilesToCreate = (stagedTargets, files, contentType) => stagedTargets.map((stagedTarget, index) => {
return {
originalSource: stagedTarget.resourceUrl,
contentType: files[index].type.includes('image') ? 'IMAGE' : 'FILE',
filename: files[index].name,
};
});

export const uploadFile = async (files, graphql) => {
const preparedFiles = prepareFiles(files);

const result = await graphql(`
mutation stagedUploadsCreate($input: [StagedUploadInput!]!) {
stagedUploadsCreate(input: $input) {
stagedTargets {
resourceUrl
url
parameters {
name
value
}
}
userErrors {
field,
message
}
}
}
`, { variables: { input: preparedFiles }});

const response = await result.json();

const promises = [];

files.forEach((file, index) => {
const url = response.data.stagedUploadsCreate.stagedTargets[index].url;
const params = response.data.stagedUploadsCreate.stagedTargets[index].parameters;
const formData = new FormData();

params.forEach((param) => {
formData.append(param.name, param.value)
})
formData.append('file', file);

const promise = fetch(url, {
method: 'POST',
body: formData,
});
promises.push(promise);
});

await Promise.all(promises);

await graphql(`
mutation fileCreate($files: [FileCreateInput!]!) {
fileCreate(files: $files) {
files {
id,
preview {
image {
url
}
}
}
userErrors {
field
message
}
}
}
`, {
variables: {
files: prepareFilesToCreate(response.data.stagedUploadsCreate.stagedTargets, files),
}
});

return {
stagedTargets: response.data.stagedUploadsCreate.stagedTargets,
errors: response.data.stagedUploadsCreate.userErrors
}
}

View solution in original post

Replies 8 (8)
Liam
Shopify Staff
Shopify Staff
1868 196 572

Hi EvilGranny,

 

It's possible that there's something happening here that's related to permissions or authentication. With the GraphiQL app, scopes and auth is already set up so since it's working with this method, it's likely your app isn't set up 100% correctly for it's scopes and auth. Just to confirm, does your app the `read_products` and `write_products` access scopes?

Liam | Developer Advocate @ Shopify 
 - Was my reply helpful? Click Like to let me know! 
 - Was your question answered? Mark it as an Accepted Solution
 - To learn more visit Shopify.dev or the Shopify Web Design and Development Blog

EvilGranny
Shopify Partner
14 2 1

Hey, thank you for your attention to my topic!

here's my scope:

[access_scopes]
# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes
scopes = "read_products,write_products,write_files,read_files"

When I didn't have some permissions, I've got an error, but now only empty object

Liam
Shopify Staff
Shopify Staff
1868 196 572

Is there any way you can test the mutation itself (eg by using something like Postman) to rule out if it's the mutation that's causing this vs if the app configuration is causing this? 

Liam | Developer Advocate @ Shopify 
 - Was my reply helpful? Click Like to let me know! 
 - Was your question answered? Mark it as an Accepted Solution
 - To learn more visit Shopify.dev or the Shopify Web Design and Development Blog

EvilGranny
Shopify Partner
14 2 1

I'd like to use postman, but I have no idea what I need to put in the headers, and which endpoint I should use ((

There's my shopify config, did I miss something

# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration

name = "shop-reviews"
client_id = "b4d0dc605b0d03899187a08ec64fb24f"
application_url = "https://household-kruger-forms-blond.trycloudflare.com"
embedded = true

[access_scopes]
# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes
scopes = "read_products,write_products,read_files,write_files"

[auth]
redirect_urls = [
"https://household-kruger-forms-blond.trycloudflare.com/auth/callback",
"https://household-kruger-forms-blond.trycloudflare.com/auth/shopify/callback",
"https://household-kruger-forms-blond.trycloudflare.com/api/auth/callback"
]

[webhooks]
api_version = "2023-07"

[pos]
embedded = false

[build]
automatically_update_urls_on_dev = true
dev_store_url = "mmg-development-store.myshopify.com"


And here's response:

00:20:58 │ remix │ result NodeResponse [Response] {
00:20:58 │ remix │ size: 0,
00:20:58 │ remix │ [Symbol(Body internals)]: {
00:20:58 │ remix │ body: ReadableStream {
00:20:58 │ remix │ _state: 'readable',
00:20:58 │ remix │ _reader: undefined,
00:20:58 │ remix │ _storedError: undefined,
00:20:58 │ remix │ _disturbed: false,
00:20:58 │ remix │ _readableStreamController: [ReadableStreamDefaultController]
00:20:58 │ remix │ },
00:20:58 │ remix │ type: 'text/plain;charset=UTF-8',
00:20:58 │ remix │ size: 2436,
00:20:58 │ remix │ boundary: null,
00:20:58 │ remix │ disturbed: false,
00:20:58 │ remix │ error: null
00:20:58 │ remix │ },
00:20:58 │ remix │ [Symbol(Response internals)]: {
00:20:58 │ remix │ url: undefined,
00:20:58 │ remix │ status: 200,
00:20:58 │ remix │ statusText: '',
00:20:58 │ remix │ headers: {
00:20:58 │ remix │ 'alt-svc': 'h3=":443"; ma=86400',
00:20:58 │ remix │ 'cf-cache-status': 'DYNAMIC',
00:20:58 │ remix │ 'cf-ray': '817bee8bbc143515-WAW',
00:20:58 │ remix │ connection: 'close',
00:20:58 │ remix │ 'content-language': 'en',
00:20:58 │ remix │ 'content-security-policy': "default-src 'self' data: blob: 'unsafe-inline' 'unsafe-eval' https://* shopify-pos://*; block-all-mixed-content; child-src 'self' https://*
shopify-pos://*; connect-src 'self' wss://* https://*; frame-ancestors 'none'; img-src 'self' data: blob: https:; script-src https://cdn.shopify.com https://cdn.shopifycdn.net
https://checkout.shopifycs.com https://api.stripe.com https://mpsnare.iesnare.com https://appcenter.intuit.com https://www.paypal.com https://js.braintreegateway.com https://c.paypal.com
https://maps.googleapis.com https://www.google-analytics.com https://v.shopify.com 'self' 'unsafe-inline' 'unsafe-eval'; upgrade-insecure-requests; report-uri
/csp-report?source%5Baction%5D=query&source%5Bapp%5D=Shopify&source%5Bcontroller%5D=admin%2Fgraphql&source%5Bsection%5D=admin_api&source%5Buuid%5D=36b53ab3-532b-404c-9613-e1202d8b750a",
00:20:58 │ remix │ 'content-type': 'application/json; charset=utf-8',
00:20:58 │ remix │ date: 'Tue, 17 Oct 2023 22:20:58 GMT',
00:20:58 │ remix │ nel: '{"success_fraction":0.01,"report_to":"cf-nel","max_age":604800}',
00:20:58 │ remix │ 'referrer-policy': 'origin-when-cross-origin',
00:20:58 │ remix │ 'report-to': '{"endpoints":[{"url":"https:\\/\\/a.nel.cloudflare.com\\/report\\/v3?s=U%2BnjgGzqnsUgTLlqdB78%2BP0lWiwU1MybCVj2sM%2Fk68dBTI9HUAx0tq2LZTjYY4xoiedpKaG7T7L6qw
mYyvPGQcGG1HOxE8%2FrwBr%2BqkJk2jssGUuWbZD7aAV7QLWRi3FGhQAI7%2BMqB%2F3R7MpP6Ofj1BP54KfR"}],"group":"cf-nel","max_age":604800}',
00:20:58 │ remix │ server: 'cloudflare',
00:20:58 │ remix │ 'server-timing': 'processing;dur=486, graphql;desc="admin/mutation/other", cfRequestDuration;dur=636.999846',
00:20:58 │ remix │ 'strict-transport-security': 'max-age=7889238',
00:20:58 │ remix │ 'transfer-encoding': 'chunked',
00:20:58 │ remix │ vary: 'Accept-Encoding',
00:20:58 │ remix │ 'x-content-type-options': 'nosniff',
00:20:58 │ remix │ 'x-dc': 'gcp-europe-west3,gcp-europe-west3,gcp-us-central1,gcp-us-central1',
00:20:58 │ remix │ 'x-download-options': 'noopen',
00:20:58 │ remix │ 'x-frame-options': 'DENY',
00:20:58 │ remix │ 'x-permitted-cross-domain-policies': 'none',
00:20:58 │ remix │ 'x-request-id': '36b53ab3-532b-404c-9613-e1202d8b750a',
00:20:58 │ remix │ 'x-shardid': '300',
00:20:58 │ remix │ 'x-shopid': '79825731885',
00:20:58 │ remix │ 'x-shopify-api-version': '2023-07',
00:20:58 │ remix │ 'x-shopify-stage': 'production',
00:20:58 │ remix │ 'x-sorting-hat-podid': '300',
00:20:58 │ remix │ 'x-sorting-hat-shopid': '79825731885',
00:20:58 │ remix │ 'x-stats-apiclientid': '66100330497',
00:20:58 │ remix │ 'x-stats-apipermissionid': '656712794413',
00:20:58 │ remix │ 'x-stats-userid': '',
00:20:58 │ remix │ 'x-xss-protection': '1; mode=block;
report=/xss-report?source%5Baction%5D=query&source%5Bapp%5D=Shopify&source%5Bcontroller%5D=admin%2Fgraphql&source%5Bsection%5D=admin_api&source%5Buuid%5D=36b53ab3-532b-404c-9613-e1202d8b750a'
00:20:58 │ remix │ },
00:20:58 │ remix │ counter: 0,
00:20:58 │ remix │ highWaterMark: undefined
00:20:58 │ remix │ }
00:20:58 │ remix │ }

 

ShopifyDevSup
Shopify Staff
Shopify Staff
1200 190 418

Hi @EvilGranny,

 

If you want to use Postman to make HTTP requests while testing your app you can format them like these examples from our documentation on using access tokens.

 

If your app uses OAuth then you will need to use the session token that was issued when you installed the app on the shop. 

 

  • The Shopify Remix template app has a section in it's README outlining how the app's default session storage works as well.

 

All the best!

 

- James

Developer Support @ Shopify
- Was this reply helpful? Click Like to let us know!
- Was your question answered? Mark it as an Accepted Solution
- To learn more visit Shopify.dev or the Shopify Web Design and Development Blog

EvilGranny
Shopify Partner
14 2 1

Thanks! I have no idea how, but I've fifxed it. But now I have a second problem.

Here's my code:

const prepareFiles = (files) => files.map(file => ({
filename: file.name,
mimeType: file.type,
resource: 'IMAGE',
fileSize: file.size.toString(),
}));

const prepareFilesToCreate = (stagedTargets, files) => stagedTargets.map((stagedTarget, index) => {
return {
originalSource: stagedTarget.resourceUrl,
contentType: 'IMAGE',
filename: files[index].name,
};
});

export const uploadFile = async (files, graphql) => {
const preparedFiles = prepareFiles(files);

const result = await graphql(`
mutation stagedUploadsCreate($input: [StagedUploadInput!]!) {
stagedUploadsCreate(input: $input) {
stagedTargets {
resourceUrl
parameters {
name
value
}
}
userErrors {
field,
message
}
}
}
`, { variables: { input: preparedFiles }});
const response = await result.json();

const filesSS = await graphql(`
mutation fileCreate($files: [FileCreateInput!]!) {
fileCreate(files: $files) {
files {
id,
preview {
image {
url
}
}
}
userErrors {
field
message
}
}
}
`, {
variables: {
files: prepareFilesToCreate(response.data.stagedUploadsCreate.stagedTargets, files),
}
});

const ll = await filesSS.json()
console.log(1111, ll.data.fileCreate.files)
console.log(5555, ll.data.fileCreate.userErrors)
return {
stagedTargets: response.data.stagedUploadsCreate.stagedTargets,
errors: response.data.stagedUploadsCreate.userErrors
}
}

And here's console.log() results:

EvilGranny_0-1697917117626.png


image is null.
I tried to use different file formats, different sizes and so on... 
On the screenshot result of jpg 400kb. 

And when I go to content=>files I see errors. What am I doing wrong?

EvilGranny_1-1697917227412.png

 

EvilGranny
Shopify Partner
14 2 1

I've found the solution. This code works, but not for video files. When I'm uploading video files, I have an error: "No content"

import {fetch, FormData} from "@remix-run/node";

const prepareFiles = (files) => files.map((file) => ({
filename: file.name,
mimeType: file.type,
resource: file.type.includes('image') ? 'IMAGE' : 'VIDEO',
fileSize: file.size.toString(),
httpMethod: 'POST',
}));

const prepareFilesToCreate = (stagedTargets, files, contentType) => stagedTargets.map((stagedTarget, index) => {
return {
originalSource: stagedTarget.resourceUrl,
contentType: files[index].type.includes('image') ? 'IMAGE' : 'VIDEO',
filename: files[index].name,
};
});

export const uploadFile = async (files, graphql) => {
const preparedFiles = prepareFiles(files);

const result = await graphql(`
mutation stagedUploadsCreate($input: [StagedUploadInput!]!) {
stagedUploadsCreate(input: $input) {
stagedTargets {
resourceUrl
url
parameters {
name
value
}
}
userErrors {
field,
message
}
}
}
`, { variables: { input: preparedFiles }});

const response = await result.json();

const promises = [];

files.forEach((file, index) => {
const url = response.data.stagedUploadsCreate.stagedTargets[index].url;
const params = response.data.stagedUploadsCreate.stagedTargets[index].parameters;
const formData = new FormData();

params.forEach((param) => {
formData.append(param.name, param.value)
})
formData.append('file', file);

const promise = fetch(url, {
method: 'POST',
body: formData,
});
promises.push(promise);
});

await Promise.all(promises);

await graphql(`
mutation fileCreate($files: [FileCreateInput!]!) {
fileCreate(files: $files) {
files {
id,
preview {
image {
url
}
}
}
userErrors {
field
message
}
}
}
`, {
variables: {
files: prepareFilesToCreate(response.data.stagedUploadsCreate.stagedTargets, files),
}
});

return {
stagedTargets: response.data.stagedUploadsCreate.stagedTargets,
errors: response.data.stagedUploadsCreate.userErrors
}
}

 

EvilGranny
Shopify Partner
14 2 1

This is an accepted solution.

Ok, this code works. I hope it will be usefull:
// sopify.app.toml

scopes = "read_products,write_products,write_files,read_files,read_themes,write_themes"


// components/filesUploader.jsx

const handleDropZoneDrop = useCallback(async (_dropFiles, acceptedFiles, _rejectedFiles) => {
const formData = new FormData();

if (acceptedFiles.length) {
setFiles(acceptedFiles);
}

acceptedFiles.forEach((file) => {
formData.append('files', file)
});

const result = await fetch('/file', {
'method': 'POST',
headers: {
contentType: 'multipart/form-data',
},
body: formData,
});
}, []);

// routes/file.jsx

import { unstable_parseMultipartFormData, unstable_createMemoryUploadHandler } from "@remix-run/node";
import { authenticate } from "~/shopify.server";
import { uploadFile } from "~/models/file.server";

export const action = async ({request}) => {
const { admin } = await authenticate.admin(request);

const uploadHandler = unstable_createMemoryUploadHandler({
maxPartSize: 20_000_000,
});

const formData = await unstable_parseMultipartFormData(
request,
uploadHandler
);

const files = formData.getAll('files');

const result = await uploadFile(files, admin.graphql);

return {
data: result,
}
}

// files.server.js

import {fetch, FormData} from "@remix-run/node";

const prepareFiles = (files) => files.map((file) => ({
filename: file.name,
mimeType: file.type,
resource: file.type.includes('image') ? 'IMAGE' : 'FILE',
fileSize: file.size.toString(),
httpMethod: 'POST',
}));

const prepareFilesToCreate = (stagedTargets, files, contentType) => stagedTargets.map((stagedTarget, index) => {
return {
originalSource: stagedTarget.resourceUrl,
contentType: files[index].type.includes('image') ? 'IMAGE' : 'FILE',
filename: files[index].name,
};
});

export const uploadFile = async (files, graphql) => {
const preparedFiles = prepareFiles(files);

const result = await graphql(`
mutation stagedUploadsCreate($input: [StagedUploadInput!]!) {
stagedUploadsCreate(input: $input) {
stagedTargets {
resourceUrl
url
parameters {
name
value
}
}
userErrors {
field,
message
}
}
}
`, { variables: { input: preparedFiles }});

const response = await result.json();

const promises = [];

files.forEach((file, index) => {
const url = response.data.stagedUploadsCreate.stagedTargets[index].url;
const params = response.data.stagedUploadsCreate.stagedTargets[index].parameters;
const formData = new FormData();

params.forEach((param) => {
formData.append(param.name, param.value)
})
formData.append('file', file);

const promise = fetch(url, {
method: 'POST',
body: formData,
});
promises.push(promise);
});

await Promise.all(promises);

await graphql(`
mutation fileCreate($files: [FileCreateInput!]!) {
fileCreate(files: $files) {
files {
id,
preview {
image {
url
}
}
}
userErrors {
field
message
}
}
}
`, {
variables: {
files: prepareFilesToCreate(response.data.stagedUploadsCreate.stagedTargets, files),
}
});

return {
stagedTargets: response.data.stagedUploadsCreate.stagedTargets,
errors: response.data.stagedUploadsCreate.userErrors
}
}