🕯️

Hono + GraphQL で UserIDを Resolver へ渡す方法

に公開

はじめに

最近、Next.js App RouterとHonoを組み合わせて、さらにGraphQLサーバーまで乗っけたアプリをハッカソンで作りました。結構開発体験は良かったのですが、GraphQLにユーザー情報をどう渡せばいいのかでちょっとハマったので、同じとこで悩む人のためにメモしておきます。

詰まった点

1.ユーザー情報(UserIDとか名前とか)をミドルウェアで検証して、GraphQLに渡したい!
2. HonoのContextにはデータ入れられるけど、GraphQLのResolverにどう渡す?かで悩む。
3. 一応渡すところまではできたけど、Contextの型付がうまくできず、Linterに怒られる…。

先に正しいコードを書いて、その後なぜ詰まったかを書くことにします。

結論 & 実装

簡単にこのコードの内容を説明すると、

  1. 認証トークンをミドルウェアで検証して、
  2. 検証したユーザー情報をHonoのContextに保存し
  3. 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です。prismapothosを使用しています。

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