空雲 Blog

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

publication: 2023/01/27
update:2023/06/22

ApolloServer3 のサポート終了は 2023/10/22

2023/1/27 現在、NestJS の公式ページに載っている方法も非推奨の 3 系統を使う方法となっています。apollo-server-expressは非推奨パッケージです。早々にApollo Server 4に移行しましょう。

以下公式サイトに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

コードの修正

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 へ移動させたりするのは状況に合わせて行ってください。

import { All, Controller, Req, Res } from "@nestjs/common"; import { ApolloServer } from "@apollo/server"; import { executeHTTPGraphQLRequest, Raw, Request, Response, } from "@node-libraries/nest-apollo-server"; import { gql } from "graphql-tag"; export const typeDefs = gql` scalar Date type Query { date: Date! } `; export const resolvers = { Query: { date: async () => { await new Promise((resolve) => setTimeout(resolve, 500)); return new Date(); }, }, }; const apolloServer = new ApolloServer({ typeDefs, resolvers, }); apolloServer.start(); @Controller("/graphql") export class GraphqlController { @All() async graphql(@Req() req: Request, @Res() res: Response) { await executeHTTPGraphQLRequest({ req, res, apolloServer, context: async () => ({ req: Raw(req), res: Raw(res) }), }); } }

StudioSandbox での動作確認

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

http://localhost:3000/graphql

Apollo の StudioSandbox が表示されます。
{"width":"1319px","height":"490px"}

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

Apollo Server 4を使用するに当たって、NestJS とやりとりする部分をパッケージ化しました。ファイルのアップロードや Express と Fastify の差異を吸収するように作ってあります。Next.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に移行しましょう。