GraphQLにおけるファイルアップロード


## 1. なぜGraphQLでファイルアップロードが問題になるのか

GraphQLはもともと**JSONベースでのやり取り**を想定して設計されています。つまり、クエリやミューテーショはJSON形式でサーバーに送られ、レスポンスもJSONで返ってくるのが標準的な流れです。バイナリファイルをそのままJSONに含めることはできないため、**ファイルアップロード**を単純に行おうとしてもハードルがあります。

一方で、写真共有アプリやユーザーアバターの設定、ドキュメント管理システムなど、ファイルのアップロードが必要になるケースは多々あります。ここで「GraphQLでどう実装するか？」という課題がしばしば出てきます

---

## 2. 一般的なアプローチ：署名付きURLを用いたアップロード

多くのプロジェクトで採用されるシンプルかつ安全な方法は、**GraphQLとは別にファイルアップロード用のエンドポイントを用意し、アップロード処理をそちらで完結させる**というパターンです。

### 2.1 具体的な流れ

1. **GraphQLミューテーションで、アップロード用URL（例: S3署名付きURL）を取得する**
    - たとえば、`getUploadUrl` というミューテーションを定義し、ファイル名やファイル種別などを引数として送ります。
    - サーバー側でAWS S3などのストレージサービスにアクセスし、一時的な署名付きURL（Presigned URL）を生成。
    - そのURLをクライアントに返す。
2. **クライアントは受け取ったURLに対して直接ファイルをアップロードする**
    - これは通常のHTTP PUTなどで行います。S3などのクラウドストレージに直接バイナリを送信できる。
    - この時点ではGraphQLを通さず、純粋にREST的なHTTP通信を行うイメージです。
3. **GraphQL側に「アップロード完了」を通知し、アップロードしたファイルのURIやIDを登録する**
    - ファイルのアップロードが完了したら、再度GraphQLのミューテーションを呼び、ファイルのURLやIDをサーバー側のDBに保存する。
    - すると、GraphQLで管理したい情報（たとえば「ユーザーのアイコンURL」「投稿の画像URI」など）とストレージ上のファイルパスが紐付けられます。

### 2.2 メリット

- **実装がシンプル**: GraphQLのJSON通信と、バイナリファイルの送信を分離することで、複雑なカスタムプロトコルを用意する必要がありません。
- **セキュリティが高い**: たとえばAWS S3では、期限付きかつ特定のオブジェクトにのみアクセス可能なPresigned URLを発行できます。これにより、特定のユーザー（リクエスト）以外からは無制限にアップロードされないように制御できます。
- **スケーラビリティ**: ファイルアップロードのトラフィックはCDNやストレージサービス側で処理され、GraphQLサーバーの負荷を回避できます。大きなファイルを大量にアップロードするケースでも、アプリケーションサーバーを圧迫しにくいのは大きな利点です。

### 2.3 デメリット

- **別のエンドポイントや手順を覚える必要がある**: 「まずはURLを取得→次にファイルをPUT→最後にGraphQLに通知」という3ステップになるため、クライアント実装がやや複雑に感じる場合があります。
- **複数リクエストの管理が必要**: 単一リクエストですべて済ませることは難しく、アップロードの成否やエラー処理をクライアント側でしっかり行う必要があります。

---

## 3. graphql-upload を使うアプローチ

### 3.1 multipart/form-data をGraphQLで扱う仕組み

一部のライブラリ（有名なのは `graphql-upload`）では、**multipart/form-data**という仕組みを使って、GraphQLリクエスト内にファイルを同梱できるようにしています。具体的には、クエリやミューテーションの「変数」の部分にファイルをマッピングする形で送ることが可能です。

```graphql

mutation uploadFile($file: Upload!) {
  uploadFile(file: $file) {
    filename
    mimetype
    encoding
    url
  }
}

```

クライアントはこのとき、`file`として選択したバイナリデータをmultipartフォームで送信し、ライブラリがそれをGraphQL resolverへ渡してくれます。

### 3.2 メリット

- **よりGraphQLの思想に近い**: 1つのGraphQLミューテーションで「ファイル+メタ情報」をまとめて送れるため、「ファイルをアップロードして、そのURLや関連情報をDBに登録する」といった操作を一括で行えます。
- **クライアント実装がシンプル（ライブラリ依存）**: ReactなどのフロントエンドではApollo Clientで`apollo-upload-client`を導入するなどして設定すれば、従来のGraphQLリクエストと同じように書けます。

### 3.3 デメリットと注意点

- **CSRF対策などセキュリティ周りがより複雑**: multipartリクエストを通す場合、通常のJSON APIと異なるセキュリティ設定が必要なケースがあります。例えば、CORS設定やCSRFトークンの適切な扱いなどに注意を払わないといけません。
- **巨大ファイルのハンドリング**: Apollo ServerやExpressサーバーがファイル本体を受け取ることになるため、負荷が高まりがちです。大容量アップロードだとサーバーがメモリを圧迫するリスクもあります。
- **クラウドストレージとの連携**: 最終的には何らかのストレージにファイルを置くのが一般的なので、GraphQLサーバーがファイルを一時的に受け取り、再度S3などに転送する手間が増えます。転送失敗の処理、タイムアウト管理など運用上の負荷も大きいです。

---

## 4. アプローチの比較

| 項目 | 署名付きURL方式 | graphql-upload方式 |
| --- | --- | --- |
| **実装の複雑さ** | クライアントは2～3段階のステップが必要だが、サーバー実装は比較的単純（URL生成のみ） | ミューテーションにバイナリを含められるが、サーバーでファイル受取処理が必要 |
| **サーバー負荷** | サーバーはファイルを直接受け取らず、トラフィックはストレージに向かう | サーバーが一旦ファイルを受け取って処理するため、メモリ/CPU負荷が高まる恐れ |
| **セキュリティ** | 署名付きURLの期限/権限管理で安全性を担保しやすい | multipart/form-dataを受け取るためCSRF・CORS設定に注意が必要 |
| **運用規模** | 大容量ファイルや大量アップロードに向き、負荷分散が容易 | 小～中規模なら利用しやすいが、大容量ファイルには向かない場合あり |
| **実装方法** | REST的なPUT/POST + GraphQLでメタ情報登録 | GraphQLリクエストでファイルも一緒に送信 (graphql-upload など) |

基本的には、**大容量ファイル**や**負荷を考慮したいプロダクション環境**では、「署名付きURL」方式を推奨する場合が多いです。逆に、**小規模プロジェクト**や**ファイルが小さいケース**などでは「graphql-upload」でミューテーションにまとめるメリットもあるでしょう。

---

## 5. 具体的なサンプルコード

### 5.1 署名付きURLの例（Apollo Server + AWS S3）

**サーバー側**（一部のみ、TypeScript仮定）:

```typescript

// resolver.ts
import { S3 } from 'aws-sdk';
const s3 = new S3();

export const resolvers = {
  Mutation: {
    async getUploadUrl(_: any, { filename }: { filename: string }) {
      const presigned = await s3.getSignedUrlPromise('putObject', {
        Bucket: 'your-bucket-name',
        Key: filename,
        Expires: 60, // 有効期限(秒)
        ContentType: 'image/png' // 必要に応じて
      });
      return presigned;
    },
    async saveFileMetadata(_: any, { filename, url }: { filename: string, url: string }, context) {
      // DBにファイル情報を登録するなどの処理
      // 例: userテーブルのavatarUrlを更新
      // ...
      return true; // 成功したら true とか返す
    }
  }
};

```

**スキーマ**:

```graphql

type Mutation {
  """
  ファイルをアップロードするための署名付きURLを取得
  """
  getUploadUrl(filename: String!): String!

  """
  アップロード完了後に、ファイルメタ情報を登録
  """
  saveFileMetadata(filename: String!, url: String!): Boolean!
}

```

**クライアント側（例: React + fetch）**:

```typescript

async function handleUpload(file) {
  // 1. GraphQLミューテーションでアップロード用URL取得
  const { data } = await apolloClient.mutate({
    mutation: gql`
      mutation ($filename: String!) {
        getUploadUrl(filename: $filename)
      }
    `,
    variables: { filename: file.name },
  });
  const presignedUrl = data.getUploadUrl;

  // 2. presignedUrl に対して PUT などでファイルをアップロード
  await fetch(presignedUrl, {
    method: 'PUT',
    headers: { 'Content-Type': file.type },
    body: file,
  });

  // 3. GraphQLでメタ情報登録
  await apolloClient.mutate({
    mutation: gql`
      mutation ($filename: String!, $url: String!) {
        saveFileMetadata(filename: $filename, url: $url)
      }
    `,
    variables: { filename: file.name, url: presignedUrl },
  });
}

```

### 5.2 graphql-uploadの例

**Apollo Server側**:

```typescript

import { ApolloServer } from 'apollo-server-express';
import { GraphQLUpload, graphqlUploadExpress } from 'graphql-upload';

const typeDefs = `
  scalar Upload

  type FileInfo {
    filename: String!
    mimetype: String!
    encoding: String!
    url: String!
  }

  type Mutation {
    uploadFile(file: Upload!): FileInfo!
  }
`;

const resolvers = {
  Upload: GraphQLUpload,
  Mutation: {
    uploadFile: async (_: any, { file }: { file: any }) => {
      const { createReadStream, filename, mimetype, encoding } = await file;

      // readStreamを使ってファイルをクラウドストレージにアップロードする
      // 例: tmpフォルダに一旦保存する場合
      const stream = createReadStream();
      // ... ストレージに保存する処理など

      const url = `https://example.com/files/${filename}`;
      return { filename, mimetype, encoding, url };
    }
  }
};

const app = express();
app.use(graphqlUploadExpress({ maxFileSize: 10000000, maxFiles: 1 })); // オプション
const server = new ApolloServer({ typeDefs, resolvers });
server.applyMiddleware({ app });

```

**クライアント側**: Apollo Clientで `apollo-upload-client` を導入し、`file` を `Upload`型にマッピングする。

```typescript
import { createUploadLink } from 'apollo-upload-client';
import { ApolloClient } from '@apollo/client';

const client = new ApolloClient({
  link: createUploadLink({ uri: '/graphql' }),
  cache: ...
});

// 使い方
const UPLOAD_FILE = gql`
  mutation UploadFile($file: Upload!) {
    uploadFile(file: $file) {
      filename
      url
    }
  }
`;

// 例: input type="file" から取得したFile
async function handleFileChange(file) {
  const { data } = await client.mutate({
    mutation: UPLOAD_FILE,
    variables: { file },
  });
  console.log('Uploaded:', data.uploadFile);
}

```

---

## 6. セキュリティと注意点

### 6.1 CORSやCSRFの考慮

- **署名付きURL**の方式では、署名URLを発行する先（S3など）に対してCORS設定を適切にしないとブラウザからのPUTが失敗する場合があります。
- **graphql-upload**方式の場合でも、`multipart/form-data`が絡むので通常のJSON-based GraphQLと挙動が変わります。CSRFトークンやセキュリティヘッダの扱いを再確認しておきましょう。

### 6.2 大容量ファイル

- 大きなファイルを直接GraphQLサーバーで受け付けると、サーバーのメモリや処理時間に負担がかかりやすくなります。これが原因でタイムアウトが発生し、結果的にユーザー体験が悪くなる恐れがあります。
- 署名付きURL方式なら、ファイル転送はクラウドストレージが直接引き受けるため、サーバー側の負荷を大きく抑えられます。

### 6.3 通信の暗号化

- HTTPS経由でアップロードするなら暗号化されますが、大容量ファイルのアップロード中にネットワークが切れた場合の再送などは考慮が必要です。いずれの方式でも**アプリケーション層でリトライ処理**を行うかどうか検討するとよいでしょう。

---

## 7. まとめ

### 7.1 推奨アプローチの選択

- **実際の本番環境で大容量ファイルを扱うのであれば**: 署名付きURLなどによってアップロードを分割するパターンが多く採用されています。サーバー負荷が低く、セキュリティやスケーラビリティ面でも優秀です。
- **比較的小さなファイルを少量だけ送る、小規模プロジェクト**: `graphql-upload` を使って簡単にまとめて実装する利点もあります。1つのGraphQLミューテーションで完結する点が魅力です。

### 7.2 全体的な注意点

- **セキュリティ**: 署名付きURLの期限管理、CORS設定、CSRF対策など。
- **サーバー負荷**: GraphQLのリゾルバがファイルを扱うのは想定外なので、必要に応じて工夫を。
- **実装工数**: 署名付きURL方式は手順が増えるが、運用が安定しやすい。一方、graphql-upload方式は手軽だが、大規模運用には向きにくい面もある。

### 7.3 参考リンク

- Apollo Docs: File uploads (Apollo Server)graphql-upload を使った導入方法、注意点がまとめられています。
- [AWS Docs: Uploading objects (AWS S3)](https://docs.aws.amazon.com/AmazonS3/latest/userguide/upload-objects.html)署名付きURLを発行してS3に直接アップロードする方法。
- [graphql-upload GitHub repo](https://github.com/jaydenseric/graphql-upload)ライブラリ本体の詳細や設定方法を確認できます。

---

## おわりに

GraphQL自体はファイルアップロードを標準でサポートしていませんが、上記のように**「別途アップロードエンドポイントを用意する」または「graphql-uploadでmultipartを扱う」**などの実装パターンが確立されています。プロジェクトの規模感や要件（ファイルサイズ、アップロード頻度、運用コストなど）に応じて最適な手法を選択するのが良いと思います。

特に本番運用では、サーバーリソースの消費を考慮すると署名付きURL方式がおすすめされることが多いです。一方で、小さいファイルをサクッとやり取りしたいだけなら、`graphql-upload`による「すべてGraphQLリクエストで完結」パターンもメリットがあります。

**「GraphQL = 単一エンドポイントですべてカバー」** という考え方に固執せず、柔軟に使い分けることが大事!