はじめに
2021/7/17現在、GraphQL APIを通じてファイルをアップロードする方法は規定されていない。そのため、GraphQL APIでファイルアップロードを行いたい場合は何らかの工夫を行う必要がある。
GraphQL APIリクエストの変数中にファイルをエンコードして送信することで、ファイルをアップロードするという目的は達成できそうに見える。が、この方法では巨大なJSONをリクエストボディとして送信する必要があり、効率がよくない。また、各種APIサーバーがファイルアップロードのためにメモリを効率的に利用する実装*1を行っていても、その恩恵を受けづらい。
GraphQL multipart request specificationという仕様に則ると、リクエストの変数中にファイルをエンコードして送信する方法と比べて、効率的にファイルをアップロードすることができる。仕様の解説やAPIサーバー側の実装方法については以下の記事が詳しいので、この記事では割愛する。
この記事では、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
型のフィールドには、アップロードするファイルを表すオブジェクト (File
や Blob
など) ではなく 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.0
や variables.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を使うことにする。
ファイルアップロードを行う場合は以下のようにネットワークレイヤを実装する必要がある。
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に従ってファイルアップロードを行うサンプルアプリケーションを作って公開した。簡単なファイルアップローダーで、最低限の機能を備えている。記事中のスキーマとは異なるけど実装の簡略化のためにそうなっている。アップロードされたファイルの情報をオンメモリに記録しているので、アプリケーションを再起動すると吹っ飛ぶ。
おわりに
この記事では、RelayでGraphQL multipart request specificationに沿ったファイルアップロードを行う際の実装方法を解説した。また、GraphQL API経由でファイルアップロードを行うサンプルアプリケーションを公開した。
この記事を読んでも分からなかったところについては、実際にコードを動かしたり弄ったりして確かめられるとよさそう。