🕯️
Hono + GraphQL で UserIDを Resolver へ渡す方法
はじめに
最近、Next.js App RouterとHonoを組み合わせて、さらにGraphQLサーバーまで乗っけたアプリをハッカソンで作りました。結構開発体験は良かったのですが、GraphQLにユーザー情報をどう渡せばいいのかでちょっとハマったので、同じとこで悩む人のためにメモしておきます。
詰まった点
1.ユーザー情報(UserIDとか名前とか)をミドルウェアで検証して、GraphQLに渡したい!
2. HonoのContextにはデータ入れられるけど、GraphQLのResolverにどう渡す?かで悩む。
3. 一応渡すところまではできたけど、Contextの型付がうまくできず、Linterに怒られる…。
先に正しいコードを書いて、その後なぜ詰まったかを書くことにします。
結論 & 実装
簡単にこのコードの内容を説明すると、
- 認証トークンをミドルウェアで検証して、
- 検証したユーザー情報をHonoのContextに保存し
- GraphQLのResolverでその情報を利用して、ユーザーごとのデータ(今回の場合は本のリスト)を取得する
という処理をしています!
登場するファイル
-
src/app/api/[...route]/route.ts
: Next.jsのAPIルートで、Honoを使ってミドルウェアやGraphQLサーバを設定するところ。 -
src/app/graphql/schema.ts
: GraphQLスキーマとResolverを定義するファイル。 -
src/app/graphql/builder.ts
: GraphQLスキーマを作るための型付けやプラグインをセットアップするファイル。
route.ts(Next.js APIルート)
src/app/api/[...route]/route.ts
import { handle } from "hono/vercel";
import { Hono } from "hono";
import { graphqlServer } from "@hono/graphql-server";
import { schema } from "../../graphql/schema.ts"; // 自分のパスに合わせて調整
type Variables = {
user: {
userId: string;
displayName: string;
pictureUrl: string;
};
token: string;
};
const app = new Hono<{ Variables: Variables }>().basePath("/api");
app.get("/", (c) => {
return c.text("Hello Hono!");
});
// トークンを検証するMock関数
async function validateToken(token: string) {
return true;
}
// User情報を返すMock関数
async function getUser(token: string) {
return {
userId: "1",
displayName: "Mock User",
pictureUrl: "https://placehold.jp/150x150.png",
};
}
// ミドルウェアでアクセストークンを検証
app.use(async (c, next) => {
const token = c.req.header("Authorization")?.split(" ")[1];
if (!token) {
return c.json({ message: "Unauthorized" }, 401);
}
const isValid = await validateToken(token);
if (!isValid) {
return c.json({ message: "Invalid token" }, 401);
}
const user = await getUser(token);
if (!user) {
return c.json({ message: "Failed to fetch user info" }, 500);
}
c.set("token", token);
c.set("user", user);
await next();
});
// ログイン処理
app.post("/login", async (c) => {
const user = c.get("user");
return c.json(
{ name: user.displayName, image: user.pictureUrl, id: user.userId },
200
);
});
// GraphQLサーバー
app.use(
"/graphql",
graphqlServer({
schema,
graphiql: true,
})
);
// Next.jsのAPIエンドポイントとしてハンドリング
export const GET = handle(app);
export const POST = handle(app);
schema.ts(ResolverでContextを使う)
src/app/graphql/schema.ts
import { writeFileSync } from "fs";
import { lexicographicSortSchema, printSchema } from "graphql";
import path from "path";
import { builder } from "./builder";
import { prisma } from "@/lib/prisma";
import { ContextType } from "@/lib/builder";
const book = builder.prismaObject("Book", {
fields: (t) => ({
bookID: t.exposeID("id", { nullable: false }),
userId: t.exposeString("userId", { nullable: false }),
title: t.exposeString("title", { nullable: false }),
}),
});
// Hono側でセットしたContextを渡せる。なお、今回必要だったのはuserIdのみ。
builder.queryType({
fields: (t) => ({
booksByUserId: t.field({
type: [book],
resolve: async (_, __, context) => {
const userId = context.get("user").userId;
const foundBooks = await prisma.book.findMany({
where: { userId: userId },
});
if(!foundBooks.length) {
throw new Error("Books not found for the given user");
}
return foundBooks;
},
}),
}),
});
export const schema = builder.toSchema();
const schemaAsString = printSchema(lexicographicSortSchema(schema));
if (process.env.NODE_ENV === "development") {
const schemaPath = path.join(process.cwd(), "src/generated/schema.graphql");
writeFileSync(schemaPath, schemaAsString);
}
builder.ts
さらに上記のコードが参照してるbuilder.tsです。prisma
とpothos
を使用しています。
src/app/graphql/builder.ts
import SchemaBuilder from "@pothos/core";
import PrismaPlugin from "@pothos/plugin-prisma";
import { DateTimeResolver } from "graphql-scalars";
import type PrismaTypes from "@/generated/pothos-types";
import { prisma } from "@/lib/prisma";
export type ContextType = {
get: (key: "user") => { userId: string };
};
export const builder = new SchemaBuilder<{
PrismaTypes: PrismaTypes;
Context: ContextType;
}>({
plugins: [PrismaPlugin],
prisma: {
client: prisma,
},
});
builder.addScalarType("DateTime", DateTimeResolver, {});
なんで詰まったの?
詰まっていた理由。それは「graphqlServer()にContextを直接渡せない」から。
こんなコード、書きたくなるけどダメです。
// 🚫これは間違い🚫
graphqlServer({
schema,
graphiql: true,
context: (c) => ({ user: c.get("user") }),
});
確かに@hono/graphql-server/dist/index.d.ts
を見ると、Contextを渡せる部分はないですね。ContextをHono経由で直接渡せない仕様、というよりschema経由で渡すような仕様だと思います。
import { GraphQLError } from 'graphql';
import type { GraphQLSchema, ValidationRule, GraphQLFormattedError } from 'graphql';
import type { Context, Env, Input, MiddlewareHandler } from 'hono';
export type RootResolver<E extends Env = any, P extends string = any, I extends Input = {}> = (c: Context<E, P, I>) => Promise<unknown> | unknown;
type Options<E extends Env = any, P extends string = any, I extends Input = {}> = {
schema: GraphQLSchema;
rootResolver?: RootResolver<E, P, I>;
pretty?: boolean;
validationRules?: ReadonlyArray<ValidationRule>;
graphiql?: boolean;
};
export declare const graphqlServer: <E extends Env = any, P extends string = any, I extends Input = {}>(options: Options<E, P, I>) => MiddlewareHandler;
【最後に】
HonoもGraphQLも型がしっかりしていて安全なのはいいけど、たまに型地獄にハマりますよね...。
でもこの記事で少しでもあなたの時間を節約できれば嬉しいです!
改善ポイントあれば、コメントをいただけると助かります👍
Discussion