NestJSと@apollo/server(v4系)によるGraphQLの実装
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 への移行をお勧めする説明は、以下の公式サイトに記載されています。
移行に備えて
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();
Apollo Server 4
を呼び出す
controller から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 を開きます。
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
に移行することをお勧めする
Discussion