私が歌川です

@utgwkk が書いている

RelayでGraphQL multipart request specificationに沿ったファイルアップロードを行う

はじめに

2021/7/17現在、GraphQL APIを通じてファイルをアップロードする方法は規定されていない。そのため、GraphQL APIでファイルアップロードを行いたい場合は何らかの工夫を行う必要がある。

GraphQL APIリクエストの変数中にファイルをエンコードして送信することで、ファイルをアップロードするという目的は達成できそうに見える。が、この方法では巨大なJSONをリクエストボディとして送信する必要があり、効率がよくない。また、各種APIサーバーがファイルアップロードのためにメモリを効率的に利用する実装*1を行っていても、その恩恵を受けづらい。

GraphQL multipart request specificationという仕様に則ると、リクエストの変数中にファイルをエンコードして送信する方法と比べて、効率的にファイルをアップロードすることができる。仕様の解説やAPIサーバー側の実装方法については以下の記事が詳しいので、この記事では割愛する。

blog.agile.esm.co.jp

この記事では、RelayでGraphQL multipart request specificationに沿ってファイルをアップロードする方法を解説する。クライアントの実装言語としてTypeScriptを用いる。

目次

スキーマ

この記事中でのスキーマは以下。Upload scalarがアップロードするファイルを表している。

type Mutation {
  uploadArtwork(input: UploadArtworkInput!): UploadArtworkPayload
}

scalar Upload

input UploadArtworkInput {
  ...

  files: [Upload!]!
}

type UploadArtworkPayload {
  artwork: Artwork
}

ファイルをアップロードするmutationを実行する

以下は2つのファイルをアップロードするmutationのコード例である。通常のmutationと同様に commitMutation 関数を使ってファイルをアップロードできるが、気をつけるべきポイントがいくつかある。

import { commitMutation } from "react-relay";

commitMutation(environment, {
  mutation: graphql`
    mutation UploadArtworkMutation($input: UploadArtworkInput!) {
      uploadArtwork(input: $input) {
        artwork {
          id
        }
      }
    }
  `,
  variables: {
    input: {
      files: [null, null],
      ...
    },
  },
  uploadables: {
    "variables.input.files.0": ...,
    "variables.input.files.1": ...,
  },
});

Upload scalarには null を渡す

mutationの入力のうち Upload 型のフィールドには、アップロードするファイルを表すオブジェクト (FileBlob など) ではなく null を渡す必要がある。ではどうやってファイルをアップロードするのかというと、後述する uploadables を使って渡す。

relay-compilerでファイルを生成する際には --customScalars.Upload null オプションを指定しておくと間違えなくて済む。

relay-compiler --schema ./schema.graphql --src src --language typescript --customScalars.Upload null

uploadables を渡す

mutationの入力中のキーに対応するアップロードするファイルのマッピングを commitMutation 関数の第2引数の uploadables フィールドに渡す必要がある。UploadableMap 型の定義は以下。

export type Uploadable = File | Blob;
export interface UploadableMap {
    [key: string]: Uploadable;
}

マッピングのキーはobject-path形式である。今回の例なら variables.input.files.0variables.input.files.1 が該当する。

複数ファイルのアップロードに対応するなら、FileList から UploadableMap に変換するヘルパー関数を作っておくと取り回しがよいと思う。

const makeUploadablesFromFileList = (
  objectPath: string,
  files: FileList
): UploadableMap =>
  Object.fromEntries<File>(
    Array.from(files, (file, i) => [`${objectPath}.${i}`, file])
  );

const uploadables = makeUploadablesFromFileList('variables.input.files', files);
// {
//   "variables.input.files.0": ...,
//   "variables.input.files.1": ...,
// }

ネットワークレイヤを実装する

Relayでは、GraphQL APIへのリクエストを行う層が抽象化されている。実装がXMLHttpRequestでもfetch APIでもよいし、なんなら単なるJavaScriptのオブジェクト操作でもよい。

今回は簡単のため、公式ドキュメントに則ってfetch APIを使うことにする。

relay.dev

ファイルアップロードを行う場合は以下のようにネットワークレイヤを実装する必要がある。

multipart form形式でHTTPリクエストを行う

通常のGraphQL APIリクエストはリクエストボディとしてJSONを送信すればよい。GraphQL multipart request specificationではmultipart form形式でリクエストを送信する必要がある。

これは単に FormData を送ればよい。

const formData = new FormData();

operations パラメータでGraphQLクエリや変数を送信する

通常のGraphQL APIリクエストでは {"query": "GraphQLクエリ", "variables": {変数}} という形式のJSONを送信すればよかった。GraphQL multipart request specificationでは operations というリクエストパラメータで、通常のGraphQL APIリクエストと同様のJSONを送信する必要がある。

formData.append(
  "operations",
  JSON.stringify({ query: params.text!, variables })
);

map パラメータで変数中のキーを送信する

map リクエストパラメータで変数中のファイルアップロードに対応するパスを送信する必要がある。

uploadable マッピングから作ると以下のようなコードになる。"variables.input.files.0": ["variables.input.files.0"] のようなフィールドを持つJSONを送信することになる。このマッピングを使って、先述した Upload 型のフィールドの null をファイルで埋めていくイメージらしい。

const map: Record<string, string[]> = {};
Object.keys(uploadables).forEach((key) => {
  if (Object.prototype.hasOwnProperty.call(uploadables, key)) {
    formData.append(key, uploadables[key]);
  }
});
formData.append("map", JSON.stringify(map));

ファイルをアップロードする

あとは FormData オブジェクトでファイルをアップロードする方法に従ってアップロードしていけばよい。 map パラメータを送信するより前にファイルを送信してはいけない*2ので、2回 Object.keys(...).forEach(...) のループを回している。

Object.keys(uploadables).forEach((key) => {
  if (Object.prototype.hasOwnProperty.call(uploadables, key)) {
    formData.append(key, uploadables[key]);
  }
});

body = formData;

完成したコード

完成したネットワークレイヤのコードは以下。通常のGraphQLクエリを発行する場合とファイルアップロードを行う場合とで分岐している。

const fetchRelay: FetchFunction = async (
  params,
  variables,
  cacheConfig,
  uploadables?
) => {
  const headers = new Headers({ Accept: "application/json" });

  let body: string | FormData;
  if (uploadables) {
    // ファイルアップロードの場合
    const formData = new FormData();
    formData.append(
      "operations",
      JSON.stringify({ query: params.text, variables })
    );

    const map: Record<string, string[]> = {};
    Object.keys(uploadables).forEach((key) => {
      if (Object.prototype.hasOwnProperty.call(uploadables, key)) {
        map[key] = [key];
      }
    });
    formData.append("map", JSON.stringify(map));

    Object.keys(uploadables).forEach((key) => {
      if (Object.prototype.hasOwnProperty.call(uploadables, key)) {
        formData.append(key, uploadables[key]);
      }
    });

    body = formData;
  } else {
    // 通常のクエリの場合
    headers.append("Content-Type", "application/json");

    body = JSON.stringify({
      query: params.text,
      variables,
    });
  }

  const response = await fetch("http://localhost:3000/api/graphql", {
    method: "POST",
    headers,
    body,
  });

  return await response.json();
};

サンプルコード

実際にRelayでGraphQL multipart request specに従ってファイルアップロードを行うサンプルアプリケーションを作って公開した。簡単なファイルアップローダーで、最低限の機能を備えている。記事中のスキーマとは異なるけど実装の簡略化のためにそうなっている。アップロードされたファイルの情報をオンメモリに記録しているので、アプリケーションを再起動すると吹っ飛ぶ。

github.com

おわりに

この記事では、RelayでGraphQL multipart request specificationに沿ったファイルアップロードを行う際の実装方法を解説した。また、GraphQL API経由でファイルアップロードを行うサンプルアプリケーションを公開した。

この記事を読んでも分からなかったところについては、実際にコードを動かしたり弄ったりして確かめられるとよさそう。

*1:アップロードされた巨大なファイルの内容を直ちにメモリに確保するのではなく、一時ファイルに退避するなど

*2:サーバーの実装によっては順序が違ってもよい場合があるけど、specでは順序が決まっているので従うべき