NestJSと@apollo/server(v4系)によるGraphQLの実装
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
1import { NestFactory } from "@nestjs/core";2import { AppModule } from "./app.module";34async function bootstrap() {5 const app = await NestFactory.create(AppModule, {6 bodyParser: false,7 });8 await app.listen(3000);9 console.log("http://localhost:3000/graphql");10}11bootstrap();
Fastify
1import { NestFactory } from "@nestjs/core";2import { AppModule } from "./app.module";3import {4 FastifyAdapter,5 NestFastifyApplication,6} from "@nestjs/platform-fastify";78async function bootstrap() {9 const fastifyAdapter = new FastifyAdapter();10 fastifyAdapter.getInstance().removeAllContentTypeParsers();11 fastifyAdapter12 .getInstance()13 .addContentTypeParser("*", { bodyLimit: 0 }, (_request, _payload, done) => {14 done(null, null);15 });16 const app = await NestFactory.create<NestFastifyApplication>(17 AppModule,18 fastifyAdapter,19 {20 bodyParser: false,21 }22 );23 await app.listen(3000);24 console.log("http://localhost:3000/graphql");25}26bootstrap();
controller からApollo Server 4を呼び出す
src/graphql/graphql.controller.ts
nest g co graphqlで作った初期コードを以下のように書き換えます。Controller にべた書きしていますが、機能を Service へ移動させたりするのは状況に合わせて行ってください。
1import { All, Controller, Req, Res } from "@nestjs/common";2import { ApolloServer } from "@apollo/server";3import {4 executeHTTPGraphQLRequest,5 Raw,6 Request,7 Response,8} from "@node-libraries/nest-apollo-server";910import { gql } from "graphql-tag";1112export const typeDefs = gql`13 scalar Date14 type Query {15 date: Date!16 }17`;1819export const resolvers = {20 Query: {21 date: async () => {22 await new Promise((resolve) => setTimeout(resolve, 500));23 return new Date();24 },25 },26};2728const apolloServer = new ApolloServer({29 typeDefs,30 resolvers,31});3233apolloServer.start();3435@Controller("/graphql")36export class GraphqlController {37 @All()38 async graphql(@Req() req: Request, @Res() res: Response) {39 await executeHTTPGraphQLRequest({40 req,41 res,42 apolloServer,43 context: async () => ({ req: Raw(req), res: Raw(res) }),44 });45 }46}
StudioSandbox での動作確認
yarn start:devで起動させたらブラウザで以下の URL を開きます。
Apollo の StudioSandbox が表示されます。
今回使ったパッケージに関して
Apollo Server 4を使用するに当たって、NestJS とやりとりする部分をパッケージ化しました。ファイルのアップロードや Express と Fastify の差異を吸収するように作ってあります。Next.js 用にも似たようなものを作っています。
@node-libraries/nest-apollo-server
ソースを載せておきます。
1import { promises as fs } from "fs";2import { parse } from "url";3import formidable from "formidable";4import {5 ApolloServer,6 BaseContext,7 ContextThunk,8 GraphQLRequest,9 HeaderMap,10 HTTPGraphQLRequest,11} from "@apollo/server";12import { IncomingMessage, ServerResponse } from "http";1314export type Request = IncomingMessage | { raw: IncomingMessage };15export type Response = ServerResponse | { raw: ServerResponse };1617/**18 * Request parameter conversion options19 */20export type FormidableOptions = formidable.Options;2122/**23 * File type used by resolver24 */25export type FormidableFile = formidable.File;2627/**28 * Convert Requests and Responses for compatibility between Express and Fastify29 */30export const Raw = <T extends IncomingMessage | ServerResponse>(31 req: T | { raw: T }32) => ("raw" in req ? req.raw : req);3334/**35 * Converting NextApiRequest to Apollo's Header36 * Identical header names are overwritten by later values37 * @returns Header in Map format38 */39export const createHeaders = (req: IncomingMessage): HeaderMap =>40 new HeaderMap(41 Object.entries(req.headers).flatMap<[string, string]>(([key, value]) =>42 Array.isArray(value)43 ? value.flatMap<[string, string]>((v) => (v ? [[key, v]] : []))44 : value45 ? [[key, value]]46 : []47 )48 );4950/**51 * Retrieve search from NextApiRequest52 * @returns search53 */54export const createSearch = (req: IncomingMessage) =>55 parse(req.url ?? "").search ?? "";5657/**58 * Make GraphQL requests multipart/form-data compliant59 * @returns [body to be set in executeHTTPGraphQLRequest, function for temporary file deletion]60 */61export const createBody = (62 req: IncomingMessage,63 options?: formidable.Options64) => {65 const form = formidable(options);66 return new Promise<[GraphQLRequest, () => void]>((resolve, reject) => {67 form.parse(req, async (error, fields, files) => {68 if (error) {69 reject(error);70 } else if (!req.headers["content-type"]?.match(/^multipart\/form-data/)) {71 resolve([72 fields,73 () => {74 //75 },76 ]);77 } else {78 if (79 "operations" in fields &&80 "map" in fields &&81 typeof fields.operations === "string" &&82 typeof fields.map === "string"83 ) {84 const request = JSON.parse(fields.operations);85 const map: { [key: string]: [string] } = JSON.parse(fields.map);86 Object.entries(map).forEach(([key, [value]]) => {87 value.split(".").reduce((a, b, index, array) => {88 if (array.length - 1 === index) a[b] = files[key];89 else return a[b];90 }, request);91 });92 const removeFiles = () => {93 Object.values(files).forEach((file) => {94 if (Array.isArray(file)) {95 file.forEach(({ filepath }) => {96 fs.rm(filepath);97 });98 } else {99 fs.rm(file.filepath);100 }101 });102 };103 resolve([request, removeFiles]);104 } else {105 reject(Error("multipart type error"));106 }107 }108 });109 });110};111112/**113 * Creating methods114 * @returns method string115 */116export const createMethod = (req: IncomingMessage) => req.method ?? "";117118/**119 * Execute a GraphQL request120 */121export const executeHTTPGraphQLRequest = async <Context extends BaseContext>({122 req: reqSrc,123 res: resSrc,124 apolloServer,125 options,126 context,127}: {128 req: Request;129 res: Response;130 apolloServer: ApolloServer<Context>;131 context: ContextThunk<Context>;132 options?: FormidableOptions;133}) => {134 const req = Raw(reqSrc);135 const res = Raw(resSrc);136 const [body, removeFiles] = await createBody(req, options);137 try {138 const httpGraphQLRequest: HTTPGraphQLRequest = {139 method: createMethod(req),140 headers: createHeaders(req),141 search: createSearch(req),142 body,143 };144 const result = await apolloServer.executeHTTPGraphQLRequest({145 httpGraphQLRequest,146 context,147 });148 res.statusCode = result.status ?? 200;149 result.headers.forEach((value, key) => {150 res.setHeader(key, value);151 });152 if (result.body.kind === "complete") {153 res.end(result.body.string);154 } else {155 for await (const chunk of result.body.asyncIterator) {156 res.write(chunk);157 }158 res.end();159 }160 return result;161 } finally {162 removeFiles();163 }164};
まとめ
Apollo Server 4に対する NestJS の公式対応が間に合ってません。しかし対応が無かったとしても、やるべき事は必要なやりとりを接続するだけなので、大して難しくはありません。
冒頭でも書きましたがApollo Server 3は非推奨なので、早々にApollo Server 4に移行しましょう。