💭

NestJSと@apollo/server(v4系)によるGraphQLの実装

2023/01/30に公開

Apollo Server 3 のサポート終了日: 2023 年 10 月 22 日

現在、2023 年 1 月 27 日時点で、NestJS の公式ページ(https://docs.nestjs.com/graphql/quick-start)に記載されている方法は、非推奨の Apollo Server 3 系を使う方法となっています。「apollo-server-express」(https://www.npmjs.com/package/apollo-server-express)は非推奨パッケージですので、早めに Apollo Server 4 に移行することをお勧めします。

Next.js 版の記事については、こちらをご覧ください(https://zenn.dev/sora_kumo/articles/bfc2fcdc9b7710)

Apollo Server 3 の終了と、Apollo Server 4 への移行をお勧めする説明は、以下の公式サイトに記載されています。

https://www.apollographql.com/docs/apollo-server/migration

移行に備えて

NestJS でApollo Server 4を最小の労力で使うには、controller から必要な情報を渡して呼び出すだけで OK です。ということでやり方を紹介します。

プログラムの作成

NestJS の基本環境作成

  • プロジェクトの作成
    nest n プロジェクト名

  • ディレクトリの移動
    cd プロジェクト名

  • 必要パッケージの追加
    yarn add @apollo/server @node-libraries/nest-apollo-server graphql graphql-tag

  • コントローラの追加
    nest g co graphql

ここまでで、Apollo Server 4を NestJS に実装する準備は完了です。

コードの修正

bodyParser の無効化

src/main.js

初期コードに対してbodyParserを無効にする設定を入れます。Fastify の方が無効化が面倒です。

  • Express
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";

async function bootstrap() {
  const app = await NestFactory.create(AppModule, {
    bodyParser: false,
  });
  await app.listen(3000);
  console.log("http://localhost:3000/graphql");
}
bootstrap();
  • Fastify
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import {
  FastifyAdapter,
  NestFastifyApplication,
} from "@nestjs/platform-fastify";

async function bootstrap() {
  const fastifyAdapter = new FastifyAdapter();
  fastifyAdapter.getInstance().removeAllContentTypeParsers();
  fastifyAdapter
    .getInstance()
    .addContentTypeParser("*", { bodyLimit: 0 }, (_request, _payload, done) => {
      done(null, null);
    });
  const app = await NestFactory.create<NestFastifyApplication>(
    AppModule,
    fastifyAdapter,
    {
      bodyParser: false,
    }
  );
  await app.listen(3000);
  console.log("http://localhost:3000/graphql");
}
bootstrap();

controller からApollo Server 4を呼び出す

src/graphql/graphql.controller.ts

nest g co graphqlで作った初期コードを以下のように書き換えます。Controller にべた書きしていますが、機能を Service へ移動させたりするのは状況に合わせて行ってください。

scalar Uploadによるバイナリのアップロード機能にも対応させてあります。Next.js+Apollo Server 4の方のライブラリにも多いのですが、multipart/form-dataにきちんと対応していないものばかりなので気をつけてください。

import { promises as fs } from "fs";
import { All, Controller, Req, Res } from "@nestjs/common";
import { ApolloServer } from "@apollo/server";
import {
  executeHTTPGraphQLRequest,
  FormidableFile,
  Raw,
  Request,
  Response,
} from "@node-libraries/nest-apollo-server";

export const typeDefs = `
  # Return date
  scalar Date
  type Query {
    date: Date!
  }

  # Return file information
  type File {
    name: String!
    type: String!
    value: String!
  }
  scalar Upload
  type Mutation {
    upload(file: Upload!): File!
  }
`;

export const resolvers = {
  Query: {
    date: async () => {
      await new Promise((resolve) => setTimeout(resolve, 500));
      return new Date();
    },
  },
  Mutation: {
    upload: async (_context, { file }: { file: FormidableFile }) => {
      return {
        name: file.originalFilename,
        type: file.mimetype,
        value: await fs.readFile(file.filepath, { encoding: "utf8" }),
      };
    },
  },
};

@Controller("/graphql")
export class GraphqlController implements OnModuleInit, OnModuleDestroy {
  apolloServer: ApolloServer;
  onModuleInit() {
    console.log("init");
    this.apolloServer = new ApolloServer({
      typeDefs,
      resolvers,
    });
    return this.apolloServer.start();
  }
  onModuleDestroy() {
    this.apolloServer.stop();
  }
  @All()
  async graphql(@Req() req: Request, @Res() res: Response) {
    await executeHTTPGraphQLRequest({
      req,
      res,
      apolloServer: this.apolloServer,
      context: async () => ({ req: Raw(req), res: Raw(res) }),
      options: {
        //Maximum upload file size set at 10 MB
        maxFileSize: 10 * 1024 * 1024,
      },
    });
  }
}

StudioSandbox での動作確認

yarn start:devで起動させたらブラウザで以下の URL を開きます。

http://localhost:3000/graphql

Apollo の StudioSandbox が表示されます。

今回使ったパッケージに関して

Apollo Server 4を使用するに当たって、NestJS とやりとりする部分をパッケージ化しました。ファイルのアップロードや Express と Fastify の差異を吸収するように作ってあります。Nest.js 用にも似たようなものを作っています。

@node-libraries/nest-apollo-server

ソースを載せておきます。

import { promises as fs } from "fs";
import { parse } from "url";
import formidable from "formidable";
import {
  ApolloServer,
  BaseContext,
  ContextThunk,
  GraphQLRequest,
  HeaderMap,
  HTTPGraphQLRequest,
} from "@apollo/server";
import { IncomingMessage, ServerResponse } from "http";

export type Request = IncomingMessage | { raw: IncomingMessage };
export type Response = ServerResponse | { raw: ServerResponse };

/**
 * Request parameter conversion options
 */
export type FormidableOptions = formidable.Options;

/**
 * File type used by resolver
 */
export type FormidableFile = formidable.File;

/**
 * Convert Requests and Responses for compatibility between Express and Fastify
 */
export const Raw = <T extends IncomingMessage | ServerResponse>(
  req: T | { raw: T }
) => ("raw" in req ? req.raw : req);

/**
 * Converting NextApiRequest to Apollo's Header
 * Identical header names are overwritten by later values
 * @returns Header in Map format
 */
export const createHeaders = (req: IncomingMessage): HeaderMap =>
  new HeaderMap(
    Object.entries(req.headers).flatMap<[string, string]>(([key, value]) =>
      Array.isArray(value)
        ? value.flatMap<[string, string]>((v) => (v ? [[key, v]] : []))
        : value
        ? [[key, value]]
        : []
    )
  );

/**
 *  Retrieve search from NextApiRequest
 * @returns search
 */
export const createSearch = (req: IncomingMessage) =>
  parse(req.url ?? "").search ?? "";

/**
 * Make GraphQL requests multipart/form-data compliant
 * @returns [body to be set in executeHTTPGraphQLRequest, function for temporary file deletion]
 */
export const createBody = (
  req: IncomingMessage,
  options?: formidable.Options
) => {
  const form = formidable(options);
  return new Promise<[GraphQLRequest, () => void]>((resolve, reject) => {
    form.parse(req, async (error, fields, files) => {
      if (error) {
        reject(error);
      } else if (!req.headers["content-type"]?.match(/^multipart\/form-data/)) {
        resolve([
          fields,
          () => {
            //
          },
        ]);
      } else {
        if (
          "operations" in fields &&
          "map" in fields &&
          typeof fields.operations === "string" &&
          typeof fields.map === "string"
        ) {
          const request = JSON.parse(fields.operations);
          const map: { [key: string]: [string] } = JSON.parse(fields.map);
          Object.entries(map).forEach(([key, [value]]) => {
            value.split(".").reduce((a, b, index, array) => {
              if (array.length - 1 === index) a[b] = files[key];
              else return a[b];
            }, request);
          });
          const removeFiles = () => {
            Object.values(files).forEach((file) => {
              if (Array.isArray(file)) {
                file.forEach(({ filepath }) => {
                  fs.rm(filepath);
                });
              } else {
                fs.rm(file.filepath);
              }
            });
          };
          resolve([request, removeFiles]);
        } else {
          reject(Error("multipart type error"));
        }
      }
    });
  });
};

/**
 * Creating methods
 * @returns method string
 */
export const createMethod = (req: IncomingMessage) => req.method ?? "";

/**
 * Execute a GraphQL request
 */
export const executeHTTPGraphQLRequest = async <Context extends BaseContext>({
  req: reqSrc,
  res: resSrc,
  apolloServer,
  options,
  context,
}: {
  req: Request;
  res: Response;
  apolloServer: ApolloServer<Context>;
  context: ContextThunk<Context>;
  options?: FormidableOptions;
}) => {
  const req = Raw(reqSrc);
  const res = Raw(resSrc);
  const [body, removeFiles] = await createBody(req, options);
  try {
    const httpGraphQLRequest: HTTPGraphQLRequest = {
      method: createMethod(req),
      headers: createHeaders(req),
      search: createSearch(req),
      body,
    };
    const result = await apolloServer.executeHTTPGraphQLRequest({
      httpGraphQLRequest,
      context,
    });
    res.statusCode = result.status ?? 200;
    result.headers.forEach((value, key) => {
      res.setHeader(key, value);
    });
    if (result.body.kind === "complete") {
      res.end(result.body.string);
    } else {
      for await (const chunk of result.body.asyncIterator) {
        res.write(chunk);
      }
      res.end();
    }
    return result;
  } finally {
    removeFiles();
  }
};

まとめ

  • Apollo Server 4に対する NestJS の公式対応が間に合っていない
  • しかし対応がなくても、必要なやりとりを接続するだけなので、大して難しくはない
  • 冒頭でも述べた通り、Apollo Server 3は非推奨なので、早めにApollo Server 4に移行することをお勧めする
GitHubで編集を提案

Discussion