2023.05.18[Thu]

Shopifyアプリから画像をShopifyにアップロードする

  • Shopify

目次

こんにちは、Webエンジニアの永井です。

今回は、Shopifyアプリから画像をShopifyにアップロードする方法を紹介します。
この方法でShopifyのストア管理画面にある [コンテンツ] → [ファイル] にアップロードできます。

こちらにアップロードすることで、自前で画像サーバーを用意する必要がなくなり、コストも削減できます。

環境

@shopify/app:3.45.4
@shopify/cli:3.45.4

npm init @shopify/app@latestを使用して、nodeで構築しています。(2023年5月現在)

画像アップロードの実装

Shopifyに画像をアップロードする流れは、次のようになります。

  1. アップロードの準備
  2. 画像のアップロード
  3. 画像ファイルの作成
  4. 画像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に画像をアップロードする方法を紹介しました。
実装が少し面倒ですが、利点も大きいので参考になれば幸いです!

Share

SpeedCurve 利用例 Favoritesタブ編SpeedCurve 利用例 Syntheticsタブ編