目次
こんにちは、Webエンジニアの永井です。
今回は、Shopifyアプリから画像をShopifyにアップロードする方法を紹介します。
この方法でShopifyのストア管理画面にある [コンテンツ] → [ファイル] にアップロードできます。
こちらにアップロードすることで、自前で画像サーバーを用意する必要がなくなり、コストも削減できます。
環境
@shopify/app:3.45.4
@shopify/cli:3.45.4
npm init @shopify/app@latest
を使用して、node
で構築しています。(2023年5月現在)
画像アップロードの実装
Shopifyに画像をアップロードする流れは、次のようになります。
- アップロードの準備
- 画像のアップロード
- 画像ファイルの作成
- 画像URLの取得(必要に応じて)
送られてくる画像データはこちらを想定して進めます。
{
"assets": [
{
"filename": "example1.jpg",
"imageData": "data:image/jpeg;base64,/9j/4AAQSkZJRgA... (Base64文字列)"
},
{
"filename": "example2.jpg",
"imageData": "data:image/jpeg;base64,/9j/4AAQSkZJRgA... (Base64文字列)"
}
...
]
}
アクセススコープの追加
画像アップロードの実装に入る前に、APIの使用に必要なアクセススコープを追加しておきます。write_products
は環境構築時のものなので、その他4つが今回の追加分です。
// shopify.app.toml
scopes = "write_products,write_files,write_themes,read_files,read_themes"
アップロードの準備
まず、stagedUploadsCreate APIを使用して、アップロードに必要なURLとパラメータを生成します。
// stagedUploadsCreateのレスポンス型定義
type StagedUploadsCreate = {
stagedUploadsCreate: {
stagedTargets: {
/** 画像アップロード用のURL */
url: string;
/** 画像アップロード後のURL。後の「画像ファイルの作成」で必要 */
resourceUrl: string;
/** 画像アップロードの認証に必要なパラメータ */
parameters: { name: string; value: string }[];
}[];
};
};
// stagedUploadsCreateのクエリ
const STAGED_UPLOADS_CREATE = `
mutation stagedUploadsCreate($input: [StagedUploadInput!]!) {
stagedUploadsCreate(input: $input) {
stagedTargets {
url
resourceUrl
parameters {
name
value
}
}
}
}
`;
// Shopify APIのGraphQLインスタンスを生成
const client = new shopify.api.clients.Graphql({ session });
// stagedUploadsCreateに渡すパラメータ
const stagedUploadInput = assets.map((asset) => ({
resource: "IMAGE",
httpMethod: "POST",
filename: asset.filename,
mimeType: asset.imageData.match(/^data:(.*?);/)?.[1],
}));
// stagedUploadsCreateの実行
const {
body: { data: stagedUploadsCreateData },
} = await client.query<{ data: StagedUploadsCreate }>({
data: {
query: STAGED_UPLOADS_CREATE,
variables: { input: stagedUploadInput },
},
});
画像のアップロード
「アップロードの準備」で生成したアップロード用のURLとパラメータを使用して画像をアップロードします。
import axios from "axios";
import FormData from "form-data";
...
const { stagedUploadsCreate: { stagedTargets } } = stagedUploadsCreateData;
const uploadPromises = stagedTargets.map(({ url, parameters }, index) => {
const { filename, imageData } = assets[index];
// Base64文字列のままだとアップロードに失敗するのでBufferに変換する
const buffer = Buffer.from(
imageData.replace(/^data:image\/png;base64,/, ""),
"base64"
);
// Base64文字列からMIMEタイプを抜き出す
const contentType = imageData.match(/^data:(.+);base64,/)?.[1];
// FormDataに画像アップロードに必要な情報を追加する
const formData = new FormData();
parameters.forEach(({ name, value }) => formData.append(name, value));
formData.append("file", buffer, {
filename,
contentType,
knownLength: buffer.length,
});
// Node.js環境ではヘッダーに`getHeaders`が必要なので忘れずに設定する
return axios.post(url, formData, { headers: formData.getHeaders() });
});
await Promise.all(uploadPromises);
画像ファイルの作成
最後に fileCreate APIを使用して、画像ファイルを作成します。
このAPIでストア管理画面の [ファイル] に画像ファイルが追加されます。
// fileCreateのレスポンス型定義
type FileCreate = {
fileCreate: {
files: {
id: string;
alt: string;
fileStatus: number;
}[];
};
};
// fileCreateのクエリ
const FILE_CREATE = `
mutation fileCreate($files: [FileCreateInput!]!) {
fileCreate(files: $files) {
files {
alt
fileStatus
... on MediaImage {
id
}
}
}
}
`;
// fileCreateに渡すパラメータ
const files = stagedTargets.map(({ resourceUrl }) => ({
contentType: "IMAGE",
originalSource: resourceUrl,
}));
// fileCreateの実行
const {
body: { data: fileCreateData },
} = await client.query<{ data: FileCreate }>({
data: { query: FILE_CREATE, variables: { files } },
});
画像URLの取得
ここまでで画像のアップロードは完了しましたが、作成した画像ファイルのURLを取得したい場合は別途実装が必要になります。
fileCreate APIのレスポンスには画像情報も含まれていますが、こちらに書かれているようにfileStatus: READY
になるまで画像情報がnull
で返されます。そのため、画像ファイルのURLを取得するにはREADY
になるまで待つする必要があります。
画像情報の取得は、ファイル情報取得 APIの files を使用します。
// filesのレスポンス型定義
type File = {
node: {
id: string;
fileStatus: "FAILED" | "PROCESSING" | "READY" | "UPLOADED";
image: {
url: string;
};
};
};
// filesのクエリ
const FILES = `
query($id: ID!) {
node(id: $id) {
id
... on MediaImage {
fileStatus
image {
url
}
}
}
}
`;
// `fileCreateData`に画像URLが含まれないので、files APIを実行してURLを取得する
const ids = fileCreateData.fileCreate.files.map((file) => file.id);
const readyFiles = await getReadyFiles(client, ids);
/**
* 対象IDのファイル情報を取得する
* ファイル情報は`fileStatus: READY`にならないと取得できないので、`READY`になるまで監視して結果を返す
*/
const getReadyFiles = async (client: GraphqlClient, fileIds: string[]) => {
let readyFiles: File["node"][] = [];
let notReadyIds = fileIds;
while (notReadyIds.length > 0) {
const fileQueries = notReadyIds.map((id) =>
client.query<{ data: File }>({
data: { query: FILES, variables: { id } },
})
);
const responses = await Promise.all(fileQueries);
const { newReadyFiles, newNotReadyIds } = responses.reduce<{
newReadyFiles: File["node"][];
newNotReadyIds: string[];
}>(
(acc, response) => {
const { node } = response.body.data;
if (node.fileStatus === "READY") {
acc.newReadyFiles.push(node);
} else {
acc.newNotReadyIds.push(node.id);
}
return acc;
},
{ newReadyFiles: [], newNotReadyIds: [] }
);
readyFiles = [...readyFiles, ...newReadyFiles];
notReadyIds = newNotReadyIds;
if (notReadyIds.length > 0) {
// 3秒待機してから再度リクエストを投げる
await new Promise((resolve) => setTimeout(resolve, 3000));
}
}
return readyFiles;
};
動作確認
全ての実装が完了したので、環境構築時の状態に少し手を加えて、動作確認していきます。
バックエンドの実装
画像アップロード用のファイルを作成して、必要な処理をまとめます。
TypeScript化は省略しているので、上で紹介した内容から型定義は削除しています。
// web/media-uploader.js
import axios from 'axios';
import FormData from 'form-data';
import shopify from './shopify.js';
// stagedUploadsCreateのクエリ
const STAGED_UPLOADS_CREATE = `
mutation stagedUploadsCreate($input: [StagedUploadInput!]!) {
stagedUploadsCreate(input: $input) {
stagedTargets {
url
resourceUrl
parameters {
name
value
}
}
}
}
`;
// fileCreateのクエリ
const FILE_CREATE = `
mutation fileCreate($files: [FileCreateInput!]!) {
fileCreate(files: $files) {
files {
alt
fileStatus
... on MediaImage {
id
}
}
}
}
`;
// filesのクエリ
const FILES = `
query($id: ID!) {
node(id: $id) {
id
... on MediaImage {
fileStatus
image {
url
}
}
}
}
`;
export default async function mediaUploader(session, assets) {
// Shopify APIのGraphQLインスタンスを生成
const client = new shopify.api.clients.Graphql({ session });
// stagedUploadsCreateに渡すパラメータ
const stagedUploadInput = assets.map((asset) => ({
resource: 'IMAGE',
httpMethod: 'POST',
filename: asset.filename,
mimeType: asset.imageData.match(/^data:(.*?);/)?.[1],
}));
// stagedUploadsCreateの実行
const {
body: { data: stagedUploadsCreateData },
} = await client.query({
data: {
query: STAGED_UPLOADS_CREATE,
variables: { input: stagedUploadInput },
},
});
const {
stagedUploadsCreate: { stagedTargets },
} = stagedUploadsCreateData;
const uploadPromises = stagedTargets.map(({ url, parameters }, index) => {
const { filename, imageData } = assets[index];
// Base64文字列のままだとアップロードに失敗するのでBufferに変換する
const buffer = Buffer.from(
imageData.replace(/^data:image\/png;base64,/, ''),
'base64',
);
// Base64文字列からMIMEタイプを抜き出す
const contentType = imageData.match(/^data:(.+);base64,/)?.[1];
// FormDataに画像アップロードに必要な情報を追加する
const formData = new FormData();
parameters.forEach(({ name, value }) => formData.append(name, value));
formData.append('file', buffer, {
filename,
contentType,
knownLength: buffer.length,
});
// Node.js環境ではヘッダーに`getHeaders`が必要なので忘れずに設定する
return axios.post(url, formData, { headers: formData.getHeaders() });
});
await Promise.all(uploadPromises);
// fileCreateに渡すパラメータ
const files = stagedTargets.map(({ resourceUrl }) => ({
contentType: 'IMAGE',
originalSource: resourceUrl,
}));
// fileCreateの実行
const {
body: { data: fileCreateData },
} = await client.query({
data: { query: FILE_CREATE, variables: { files } },
});
// `fileCreateData`に画像URLが含まれないので、files APIを実行してURLを取得する
const ids = fileCreateData.fileCreate.files.map((file) => file.id);
const readyFiles = await getReadyFiles(client, ids);
return readyFiles;
}
/**
* 対象IDのファイル情報を取得する
* ファイル情報は`fileStatus: READY`にならないと取得できないので、`READY`になるまで監視して結果を返す
*/
const getReadyFiles = async (client, fileIds) => {
let readyFiles = [];
let notReadyIds = fileIds;
while (notReadyIds.length > 0) {
const fileQueries = notReadyIds.map((id) =>
client.query({
data: { query: FILES, variables: { id } },
}),
);
const responses = await Promise.all(fileQueries);
const { newReadyFiles, newNotReadyIds } = responses.reduce(
(acc, response) => {
const { node } = response.body.data;
if (node.fileStatus === 'READY') {
acc.newReadyFiles.push(node);
} else {
acc.newNotReadyIds.push(node.id);
}
return acc;
},
{ newReadyFiles: [], newNotReadyIds: [] },
);
readyFiles = [...readyFiles, ...newReadyFiles];
notReadyIds = newNotReadyIds;
if (notReadyIds.length > 0) {
// 3秒待機してから再度リクエストを投げる
await new Promise((resolve) => setTimeout(resolve, 3000));
}
}
return readyFiles;
};
画像アップロード用の処理を呼び出せるようにAPIルートを設定します。
// web/index.js
...
import mediaUploader from './media-uploader.js';
...
app.post('/api/media/create', async (_req, res) => {
let status = 200;
let error = null;
let images = [];
try {
images = await mediaUploader(res.locals.shopify.session, _req.body.assets);
} catch (e) {
console.log(`Failed to process media/create: ${e.message}`);
status = 500;
error = e.message;
}
res.status(status).send({ success: status === 200, error, images });
});
フロントエンドの実装
画像アップロード用のUIを追加して、画像アップロードができるようにします。
画像アップロード用のコンポーネントを作成します。
// web/frontend/components/MediaCard.jsx
import {
Card,
DropZone,
HorizontalStack,
Image,
VerticalStack,
} from '@shopify/polaris';
import { useCallback, useState } from 'react';
import { useAuthenticatedFetch } from '../hooks';
/** アップロード可能な画像種別 */
const validImageTypes = ['image/gif', 'image/jpeg', 'image/png'];
const readFile = (file) => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => {
resolve(reader.result);
};
reader.readAsDataURL(file);
});
};
export const MediaCard = () => {
const [imageFiles, setImageFiles] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const fetch = useAuthenticatedFetch();
const handleMediaUpload = useCallback(async () => {
setIsLoading(true);
const assets = [];
for (const file of imageFiles) {
assets.push({
filename: file.name,
imageData: await readFile(file),
});
}
const response = await fetch('/api/media/create', {
method: 'POST',
body: JSON.stringify({ assets }),
headers: {
'Content-Type': 'application/json',
},
});
setImageFiles([]);
setIsLoading(false);
}, [imageFiles]);
return (
<Card
title='Media Uploader'
sectioned
primaryFooterAction={{
content: 'Media upload',
onAction: handleMediaUpload,
loading: isLoading,
}}
>
<VerticalStack gap='5'>
<DropZone
accept={validImageTypes.join(',')}
type='image'
onDropAccepted={setImageFiles}
>
<DropZone.FileUpload actionHint='or drop images to upload' />
</DropZone>
<HorizontalStack gap='3'>
{imageFiles.map((file) => (
<Image
alt={file.name}
source={window.URL.createObjectURL(file)}
width='15%'
height='15%'
/>
))}
</HorizontalStack>
</VerticalStack>
</Card>
);
};
ホームページで表示されるようにコンポーネントを呼び出します。
// web/frontend/pages/index.jsx
...
export default function HomePage() {
return (
...
<Layout.Section>
<MediaCard />
</Layout.Section>
</Layout>
</Page>
);
}
実行
バックエンド/フロントエンドの実装が完了したので実行します。
アップロードしたい画像を選択して、「Media upload」ボタンをクリックします。
POST media/create
が実行され、ステータス200で返ってきています。画像情報も取得できています。
ストア管理画面の [ファイル] にも問題なく追加されていました!
まとめ
Shopifyに画像をアップロードする方法を紹介しました。
実装が少し面倒ですが、利点も大きいので参考になれば幸いです!