NestJS + Fastify + Mercurius でGraphQLのファイルアップロードを実装する

ノッパクン
ノッパクン

今年もあと5ヶ月しかないと思うと早いですね!歳を重ねるにつれて時間が早いのなんの・・・!どうもノッパクンです。

概要

以前の記事でもご紹介したNestJS + Fastify + Mercuriusの組み合わせですが、GraphQLでのファイルアップロードの方法を調べても中々見つからなかったので、自分で検証して実現した方法をまとめてみました。

TypeScriptでGraphQLを開発するならどのフレームワークがおすすめ?モダンなフレームワークを全体比較!

おそらくこの方法が一番簡単でシンプルだと思いますが、もっと良い方法があればご連絡いただけると嬉しいです。

今回の主役

今回ファイルアップロードを簡単に実現するために、mercurius-uploadというモジュールを使用します。

通常、ファイルのアップロードでは multipart/form-data のリクエストを許可する必要がありますが、プラグイン内部でその設定を行ってくれます。

また、プラグインの内部ではgraphql-uploadによるリクエストの変換が行われ、この変換された値(ファイル情報)をカスタムスカラーで受け取ります。カスタムスカラーでファイルのReadStream (stream.Readable)を作成して、リゾルバーに渡す流れになります。

リクエスト → プラグイン → リゾルバー(stream)

それでは、実際に実装に移っていきましょう!

ファイルアップロード実装

最終的に以下の画像ようにミニマムなファイル構成を作成します。→Github

今回は実装を簡略にするためスキーマファーストを採用するため、graphql.tsはプログラム実行時に自動で作成されます。

プロジェクト初期化&インストール

最初に基盤となるアプリケーションをCLIで作成します。

$ nest new nestjs-fastify-mercrius-upload
> npm

続いて、Fastify + MercuriusのGraphQL構築に必要最低限なモジュールを追加でインストールします。

$ npm i ts-morph @nestjs/graphql @nestjs/mercurius graphql mercurius @mercuriusjs/gateway @nestjs/platform-fastify npm i mercurius-upload

アプリのベース作成

main.tsをFastify定義内容で書き換えていきます。mercurius-uploadプラグインの追加はここで行います。

import { NestFactory } from '@nestjs/core';
import {
  FastifyAdapter,
  NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { AppModule } from './app.module';
import mercuriusUploadPlugin from 'mercurius-upload';

async function bootstrap() {
  const app = await NestFactory.create<NestFastifyApplication>(
    AppModule,
    new FastifyAdapter(),
  );
  app.register(mercuriusUploadPlugin);
  await app.listen(3000);
  console.log('http://localhost:3000/graphql');
}
bootstrap();

次にapp.module.tsを編集します。元々定義されていたAppControllerやAppServiceは、今回使用しないため関連のファイルと一緒に消してしまいましょう。

かわりに、GraphQL(Mercrius)の定義を追加します。UploadScalarAppResolverは後から作成しますので、作成が完了したらprovidersに追加してください。

import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { MercuriusDriver, MercuriusDriverConfig } from '@nestjs/mercurius';
import { join } from 'path';
import { UploadScalar } from './UploadScalar';
import { AppResolver } from './app.resolver';

@Module({
  imports: [
    GraphQLModule.forRoot<MercuriusDriverConfig>({
      driver: MercuriusDriver,
      typePaths: ['./**/*.graphql'],
      definitions: {
        path: join(process.cwd(), 'src/graphql.ts'),
        outputAs: 'class',
      },
    }),
  ],
  providers: [UploadScalar, AppResolver],
})
export class AppModule {}

スキーマ・リゾルバー・スカラーの作成

スキーマは以下のように定義します。クエリ定義は必須のため、ダミーで定義は追加しますが実装はしません。本記事のメインはミューテーションのupload()になります。

scalar Upload

type Query {
  hello: String! #dummy
}

type Mutation {
  upload(file: Upload!): Boolean!
}

続いてファイルアップロード用のスカラーを定義します。

プラグインから受け取れる値としては、filename, mimetype, encoding, createReadStreamの4つになります。streamはここで作成してリゾルバーに渡してあげましょう!

import {
  BadRequestException,
  InternalServerErrorException,
} from '@nestjs/common';
import { Scalar, CustomScalar } from '@nestjs/graphql';
import { ReadStream } from 'fs';

interface PluginValue {
  file: {
    filename: string;
    mimetype: string;
    encoding: string;
    createReadStream: () => ReadStream;
  };
}

export interface UploadFile {
  fileName: string;
  mimeType: string;
  encoding: string;
  readStream: ReadStream;
}

@Scalar('Upload')
export class UploadScalar implements CustomScalar<any, UploadFile> {
  description = 'ファイルアップロードで使用するスカラです。';

  parseValue(value: PluginValue): UploadFile {
    const { filename, mimetype, encoding, createReadStream } = value.file;
    const readStream = createReadStream();
    return { fileName: filename, mimeType: mimetype, encoding, readStream };
  }

  parseLiteral(value: any): UploadFile {
    throw new BadRequestException('リテラル指定はできません。');
  }

  serialize(value: any): UploadFile {
    throw new InternalServerErrorException('返却には使用できません。');
  }
}

リゾルバーでは、受け取ったreadStreamを煮るなり焼くなりしていただければ良いですが、ここではコンソールログに出力するだけのシンプルな内容にとどめておきます。

import { Args, Mutation, Resolver } from '@nestjs/graphql';
import { UploadFile } from './UploadScalar';

@Resolver()
export class AppResolver {
  constructor() {}

  @Mutation()
  async upload(
    @Args('file')
    file: UploadFile,
  ): Promise<boolean> {
    const { readStream, ...another } = file;
    console.log(`★start upload : ${JSON.stringify(another)}`);

    const chunks = [];
    readStream.on('data', (buf) => chunks.push(buf));
    readStream.on('end', () => console.log(Buffer.concat(chunks).toString()));
    return true;
  }
}

動作確認

GraphQLサーバーの起動

作成したプロジェクトでサーバーを起動します。

$ npm run start:dev

[12:24:29] Starting compilation in watch mode...

[12:24:30] Found 0 errors. Watching for file changes.

[Nest] 35373  - 2023/08/05 12:24:30     LOG [NestFactory] Starting Nest application...
[Nest] 35373  - 2023/08/05 12:24:30     LOG [InstanceLoader] AppModule dependencies initialized +10ms
[Nest] 35373  - 2023/08/05 12:24:30     LOG [InstanceLoader] GraphQLSchemaBuilderModule dependencies initialized +0ms
[Nest] 35373  - 2023/08/05 12:24:30     LOG [InstanceLoader] GraphQLModule dependencies initialized +1ms
[Nest] 35373  - 2023/08/05 12:24:30     LOG [GraphQLModule] Mapped {/graphql, POST} route +157ms
[Nest] 35373  - 2023/08/05 12:24:30     LOG [NestApplication] Nest application successfully started +0ms
http://localhost:3000/graphql

ファイル作成

アップロードの動作確認に使用するテキストファイルを作成します。

これはテストです。
This is Test!

動作確認用ツール

次に動作確認に使用するGraphQLツールをインストールします。今回はファイル添付が簡単に行えるGraphQLツールAltair GraphQL Clientを使用します。

Altair GraphQL Clientを起動して、リクエスト先には「http://localhost:3000/graphql」、クエリには以下のMutationを打ち込みます。

mutation ($file: Upload!) {
  upload(file: $file)
}

ツール画面右下のAdd filesより、先程作成したテキストファイルを選択します。この状態でSend Requestを実行すると、GraphQLよりレスポンスが返却されます。

結果の確認

サーバーを起動したコンソール上にファイルの情報とファイルの内容が出力されていたら成功です。動作確認は以上です。

[12:24:29] Starting compilation in watch mode...

[12:24:30] Found 0 errors. Watching for file changes.

[Nest] 35373  - 2023/08/05 12:24:30     LOG [NestFactory] Starting Nest application...
[Nest] 35373  - 2023/08/05 12:24:30     LOG [InstanceLoader] AppModule dependencies initialized +10ms
[Nest] 35373  - 2023/08/05 12:24:30     LOG [InstanceLoader] GraphQLSchemaBuilderModule dependencies initialized +0ms
[Nest] 35373  - 2023/08/05 12:24:30     LOG [InstanceLoader] GraphQLModule dependencies initialized +1ms
[Nest] 35373  - 2023/08/05 12:24:30     LOG [GraphQLModule] Mapped {/graphql, POST} route +157ms
[Nest] 35373  - 2023/08/05 12:24:30     LOG [NestApplication] Nest application successfully started +0ms
http://localhost:3000/graphql
★start upload : {"fileName":"test.txt","mimeType":"text/plain","encoding":"7bit"}
これはテストです。
This is Test!

まとめ

  • NestJS + Fastify + Mercuriusのファイルアップロードではプラグインのmercurius-uploadを使用すると実装が楽です。
  • GraphQL実行でファイルを添付するにはAltair GraphQL Clientが簡単です。
ノッパクン
ノッパクン

今日は地元の花火大会みたいなのでいってきます!花火は一年に数回しかみないので、歳をとっても新鮮な気持ちでみれますね。