はじめに
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
型のフィールドには、アップロードするファイルを表すオブジェクト (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);
ネットワークレイヤを実装する
Relayでは、GraphQL APIへのリクエストを行う層が抽象化されている。実装がXMLHttpRequestでもfetch APIでもよいし、なんなら単なるJavaScriptのオブジェクト操作でもよい。
今回は簡単のため、公式ドキュメントに則ってfetch APIを使うことにする。
relay.dev
ファイルアップロードを行う場合は以下のようにネットワークレイヤを実装する必要がある。
通常の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経由でファイルアップロードを行うサンプルアプリケーションを公開した。
この記事を読んでも分からなかったところについては、実際にコードを動かしたり弄ったりして確かめられるとよさそう。