NestJS + GraphQL + Apollo Client でファイルのアップロードを実装するには

# 前提
- Apollo Clientなどを使ってGraphQLのコードを書く場合にリクエストの方法を意識することはあまりないが、実態は`content-type: application/json`のPOSTメソッドでbodyにGraphQLのクエリを記述してリクエストしているだけである
- ファイルのアップロードを行う場合は通常`content-type: multipart/form-data`である必要があるため、それに対応する一工夫が必要となる
- NestJS + GraphQL + Apollo Client の構成での実装例が見つからなかったので備忘録として残しておく

# 実装方法

## NestJS側でやること

### npm/yarnで`graphql-upload`を追加
- `graphql-upload`を使用するためインストールしておきます
  - https://github.com/jaydenseric/graphql-upload
```zsh
yarn add graphql-upload
yarn add -D @types/graphql-upload
```

### AppModuleのmiddlewareとして`graphqlUploadExpress`を追加
- これにより`multipart/form-data`のリクエストを処理できるようになります

```diff_typescript:app.module.ts
- import { Module } from "@nestjs/common";
+ import { MiddlewareConsumer, Module } from "@nestjs/common";
+ import { graphqlUploadExpress } from "graphql-upload";

@Module({
  imports: [
    GraphQLModule.forRoot(),
  ],
})
export class AppModule {
+  configure(consumer: MiddlewareConsumer) {
+    consumer.apply(graphqlUploadExpress()).forRoutes("graphql");
+  }
}
```

### Resolverでfileを受け取る
- 例えば次のようにfileを受け取ります
  - GraphQLのtypeは`GraphQLUpload`を指定します
  - 変数の型は`FileUpload`を使用します

```profileImage.resolver.ts
import { Args, Mutation, Resolver } from "@nestjs/graphql";
import { FileUpload, GraphQLUpload } from "graphql-upload";

@Resolver()
export class ProfileImageResolver {
  @Mutation((returns) => ProfileImage)
  async uploadProfileImage(@Args({ name: "file", type: () => GraphQLUpload }) file: FileUpload) {
    console.log(file);
  }
}

```

- GraphQLのCode Firstで実装している場合、次のようにGraphQLのschemaが生成されるはずです

```schema.graphql
type Mutation {
  uploadProfileImage(file: Upload!): PrifileImage!
}

"""The `Upload` scalar type represents a file upload."""
scalar Upload
```

- NestJS側の準備は以上です
- 参考: https://github.com/nestjs/graphql/issues/901#issuecomment-780007582

## Apollo Client側でやること

### npm/yarnで`apollo-upload-client`を追加
- `apollo-upload-client`を使用するためインストールしておきます
  - https://github.com/jaydenseric/apollo-upload-client

```zsh
yarn add apollo-upload-client
yarn add -D @types/apollo-upload-client
```

### `createHttpLink`を`createUploadLink`に置き換える
- Apollo Clientで`multipart/form-data`を処理できるように`createHttpLink`の代わりに`createUploadLink`を使用します
- 置き換えた後、ファイルアップロードを伴わない通常のリクエストも正常に動作し続けます

```diff_typescript
import { ApolloClient, InMemoryCache, from } from "@apollo/client";
import { createUploadLink } from "apollo-upload-client";

- const httpLink = createHttpLink({
+ const uploadLink = createUploadLink({
  uri: `${APOLLO_URI}/graphql`,
});

export const client = new ApolloClient({
-   link: from([httpLink]),
+   link: from([uploadLink]),
});
```

### GraphQL Code Generatorを使う場合は`scalar Upload`に対応する
- 前段の`schema.graphgal`で`scalar Upload`として定義したものをFile型で扱うように定義しておきます

```diff_yaml:codegen.yaml
overwrite: true
schema: "../backend/schema.graphql"
documents:
  - ./graphql/queries/*.graphql
  - ./graphql/mutations/*.graphql
generates:
  graphql/generated.ts:
    plugins:
      - "typescript"
      - "typescript-operations"
      - "typescript-react-apollo"
+     config:
+       scalars:
+        Upload: File
```

- 例えば次のようにクエリを書きます

```graphql/mutations/uploadProfileImage.graphql
mutation uploadProfileImage($file: Upload!) {
  uploadProfileImage(file: $file) {
    id
  }
}
```

- 上記でcodegenを行うとReact Hooksでは次のように使用することができます

```tsx
import { useUploadProfileImageMutation } from "../graphql/generated";

const [uploadProfileImage] = useUploadProfileImageMutation();

const handleUploadProfileImage = useCallback(
  async (file: File) => {
    await uploadProfileImage({
      variables: { file },
    });
  },
  [uploadProfileImage],
);
```

- 参考: https://stackoverflow.com/questions/49507035


## おまけ: Cloud Storageへのアップロード方法
- Resolverで受け取ったfileをGCPのCloudStorageににファイルをアップロードする処理もついでに残しておきます

```typescript
import { Storage } from "@google-cloud/storage";
import { FileUpload } from "graphql-upload";

const uploadFileToCloudStorage = async (file: FileUpload) => {
  const storage = new Storage({
    projectId: process.env.GCP_PROJECT_ID,
    credentials: {
      client_email: process.env.GCP_CLIENT_EMAIL,
      private_key: process.env.GCP_PRIVATE_KEY.replace(/\\n/g, "\n"),
    },
  });

  const bucket = await storage.bucket(process.env.CLOUD_STORAGE_BUCKET_NAME);
  const targetFile = bucket.file(file.fileName);
  const result = await new Promise<boolean>((resolve, reject) =>
    file
      .createReadStream()
      .pipe(targetFile.createWriteStream())
      .on("finish", () => resolve(true))
      .on("error", () => reject(false)),
  );
}
```

# 以上！
もっとGraphQL使いこなしていきたい :muscle: