空雲 Blog

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

publication: 2023/01/27
update:2024/02/20

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";
3
4async 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";
7
8async function bootstrap() {
9 const fastifyAdapter = new FastifyAdapter();
10 fastifyAdapter.getInstance().removeAllContentTypeParsers();
11 fastifyAdapter
12 .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";
9
10import { gql } from "graphql-tag";
11
12export const typeDefs = gql`
13 scalar Date
14 type Query {
15 date: Date!
16 }
17`;
18
19export const resolvers = {
20 Query: {
21 date: async () => {
22 await new Promise((resolve) => setTimeout(resolve, 500));
23 return new Date();
24 },
25 },
26};
27
28const apolloServer = new ApolloServer({
29 typeDefs,
30 resolvers,
31});
32
33apolloServer.start();
34
35@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 を開きます。

http://localhost:3000/graphql

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

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

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";
13
14export type Request = IncomingMessage | { raw: IncomingMessage };
15export type Response = ServerResponse | { raw: ServerResponse };
16
17/**
18 * Request parameter conversion options
19 */
20export type FormidableOptions = formidable.Options;
21
22/**
23 * File type used by resolver
24 */
25export type FormidableFile = formidable.File;
26
27/**
28 * Convert Requests and Responses for compatibility between Express and Fastify
29 */
30export const Raw = <T extends IncomingMessage | ServerResponse>(
31 req: T | { raw: T }
32) => ("raw" in req ? req.raw : req);
33
34/**
35 * Converting NextApiRequest to Apollo's Header
36 * Identical header names are overwritten by later values
37 * @returns Header in Map format
38 */
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 : value
45 ? [[key, value]]
46 : []
47 )
48 );
49
50/**
51 * Retrieve search from NextApiRequest
52 * @returns search
53 */
54export const createSearch = (req: IncomingMessage) =>
55 parse(req.url ?? "").search ?? "";
56
57/**
58 * Make GraphQL requests multipart/form-data compliant
59 * @returns [body to be set in executeHTTPGraphQLRequest, function for temporary file deletion]
60 */
61export const createBody = (
62 req: IncomingMessage,
63 options?: formidable.Options
64) => {
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};
111
112/**
113 * Creating methods
114 * @returns method string
115 */
116export const createMethod = (req: IncomingMessage) => req.method ?? "";
117
118/**
119 * Execute a GraphQL request
120 */
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に移行しましょう。