Web アプリケーションフレームワークの Hono にはさまざまなミドルウェアが提供されています。その一つが Hono GraphQL Server Middleware で、Hono をベースとした GraphQL サーバーを構築できます。
「よし Hono で GraphQL サーバー書くぞ!」という人のために公式の README にサンプルコードが用意されているのですが。これが本当に最小限で、初見で走らせてみるのは情報不足と感じます。そこでもう一歩踏み込んだ実践的なサンプルコードを用意しました。
ここで動作している様子も見られます。
hono-graphql-server-example.todays-mitsui.workers.dev
この記事ではサンプルコードを解説します。
目次
- 目次
- まずはローカルで動かそう
- リゾルバを追加定義してみる
- さらに発展的な例: Field Resolver
- おまけ: Hono GraphQL Server Middleware の実装を覗いてみる
- まとめ
まずはローカルで動かそう
Hono GraphQL Server Middleware は "Server" と名前に冠していますがサーバーとしての機能がありません。まずはローカルで動かしてリクエストを送ってみたいところですが公式のサンプルコードにはローカルで動かす方法は書かれていません。
// index.ts import { Hono } from 'hono' import { type RootResolver, graphqlServer } from '@hono/graphql-server' import { buildSchema } from 'graphql' export const app = new Hono() const schema = buildSchema(` type Query { hello: String } `) const rootResolver: RootResolver = (c) => { return { hello: () => 'Hello Hono!', } } app.use( '/graphql', graphqlServer({ schema, rootResolver, graphiql: true, }) ) app.fire()
これをそのまま tsx コマンド で実行しようとしてもエラーになります。
$ tsx index.ts file:///Users/todays_mitsui/oss/hono-graphql-server-example/node_modules/hono/dist/hono-base.js:224 addEventListener("fetch", (event) => { ^ ReferenceError: addEventListener is not defined at Hono.fire (file:///Users/todays_mitsui/oss/hono-graphql-server-example/node_modules/hono/dist/hono-base.js:224:5) at <anonymous> (/Users/todays_mitsui/oss/hono-graphql-server-example/index.ts:28:5) at ModuleJob.run (node:internal/modules/esm/module_job:274:25) at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:644:26) at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:98:5) Node.js v23.11.0
サンプルコードをローカルで動かす方法はいくつかあります。
@hono/node-server を使う方法と wrangler コマンドを使う方法です。それぞれ見てみましょう。
@hono/node-server を使う方法
@hono/node-server をインストールしてから import します。
// index.ts + import { serve } from '@hono/node-server' import { Hono } from 'hono' import { type RootResolver, graphqlServer } from '@hono/graphql-server' import { buildSchema } from 'graphql' const app = new Hono() const schema = buildSchema(` type Query { hello: String } `) const rootResolver: RootResolver = (c) => { return { hello: () => 'Hello Hono!', } } app.use( '/graphql', graphqlServer({ schema, rootResolver, graphiql: true, }) ) - app.fire() + serve(app, (info) => { + console.log(`Listening on http://localhost:${info.port}/graphql`) + })
するとこのコードを実行するだけでローカルサーバーが立ち上がるようになります。
$ tsx index.ts Listening on http://localhost:3000/graphql
wrangler コマンドを使う方法
Hono がデフォルトで Web サーバーとしての機能を持っていないのは、Claoudflare Workers などの環境で Service Worker として動作することを第一に設計されているからです。そのために Hono も Hono GraphQL Server もデフォルトではアプリケーションサーバーとして動作するようにはなっていません。
公式のサンプルコードはローカルで動かせる形にはなっていませんが、代わりに Claoudflare Workers にそのままデプロイ可能な形になっています。そこで Cloudflare 公式が提供している wrangler コマンド を使いましょう。wrangler コマンドにはコードをローカルで実行してプレビューする機能もあります。
$ npm install --save-dev wrangler
そして npm scripts としてプレビュー用のコマンドを書きます。
// package.json { "scripts": { "dev": "wrangler dev --port=3000 index.ts" }, "dependencies": { "@hono/graphql-server": "^0.5.1", "hono": "^4.7.7" }, "devDependencies": { "wrangler": "^4.12.0" } }
TypeScript コードはサンプルそのままです。
// index.ts import { Hono } from 'hono' import { type RootResolver, graphqlServer } from '@hono/graphql-server' import { buildSchema } from 'graphql' export const app = new Hono() const schema = buildSchema(` type Query { hello: String } `) const rootResolver: RootResolver = (c) => { return { hello: () => 'Hello Hono!', } } app.use( '/graphql', graphqlServer({ schema, rootResolver, graphiql: true, }) ) app.fire()
npm run dev
でローカルサーバーが立ち上がります。
$ npm run dev > dev > wrangler dev --port=3000 index.ts ⛅️ wrangler 4.14.1 ------------------- ▲ [WARNING] The entrypoint index.ts has exports like an ES Module, but hasn't defined a default export like a module worker normally would. Building the worker using "service-worker" format... No bindings found. ⎔ Starting local server... [wrangler:inf] Ready on http://localhost:3000
おっと何やら WARNING が出ていますね。修正しましょう。
// index.ts import { Hono } from 'hono' import { type RootResolver, graphqlServer } from '@hono/graphql-server' import { buildSchema } from 'graphql' - export const app = new Hono() + const app = new Hono() const schema = buildSchema(` type Query { hello: String } `) const rootResolver: RootResolver = (c) => { return { hello: () => 'Hello Hono!', } } app.use( '/graphql', graphqlServer({ schema, rootResolver, graphiql: true, }) ) - app.fire() + export default app
app.fire()
を削除し app
自体を default export するように変更しました。
再度 npm run dev
すると WARNING 無しでローカルサーバーが立ち上がります。
$ npm run dev > dev > wrangler dev --port=3000 index.ts ⛅️ wrangler 4.14.1 ------------------- No bindings found. ⎔ Starting local server... [wrangler:inf] Ready on http://localhost:3000
リゾルバを追加定義してみる
公式ドキュメントには定数として 'Hello Hono!'
を返すだけのやる気のないリゾルバしか定義されていません。
const rootResolver: RootResolver = (c) => { return { hello: () => 'Hello Hono!', } }
もう少し実用的なリゾルバを定義してみましょう。
結果だけ先に見たい人のために完成品のコードがここにあります。
スキーマとデータ
リゾルバを実装する前にスキーマを定義しておきます。
const typeDefs = buildSchema(` type Author { id: Int! name: String! firstName: String! lastName: String! books(findTitle: String): [Book!]! } type Book { id: Int! title: String! author: Author } type Query { hello(name: String): String! book(title: String!): Book books: [Book!]! } `);
書籍と作者を検索できる感じにしました。
Book.author
で書籍から作者を参照できます。 Author.books
で作者から書籍の一覧を参照できます。
あくまでも例なのでデータベースなど用意せずコード中に直接書いたデータを返すようにします。[src/graphql/data.ts https://github.com/todays-mitsui/hono-graphql-server-example/blob/main/src/graphql/data.ts] にサンプルデータを置いて使います。
// src/graphql/data.ts export const authors: Author[] = [ { id: 0, lastName: "夏目", firstName: "漱石" }, { id: 1, lastName: "芥川", firstName: "龍之介" }, { id: 2, lastName: "太宰", firstName: "治" }, { id: 3, lastName: "川端", firstName: "康成" }, { id: 4, lastName: "宮沢", firstName: "賢治" }, { id: 5, lastName: "森", firstName: "鷗外" }, ]; export const books: Book[] = [ { id: 0, title: "吾輩は猫である", authorId: 0 }, { id: 1, title: "こころ", authorId: 0 }, { id: 2, title: "羅生門", authorId: 1 }, { id: 3, title: "人間失格", authorId: 2 }, { id: 4, title: "斜陽", authorId: 2 }, { id: 5, title: "走れメロス", authorId: 2 }, { id: 6, title: "雪国", authorId: 3 }, { id: 7, title: "銀河鉄道の夜", authorId: 4 }, { id: 8, title: "舞姫", authorId: 5 }, { id: 9, title: "伊勢物語", authorId: null }, ];
リゾルバ: 書籍検索
書籍名を指定して書籍情報を返すリゾルバを実装します。スキーマでは Query.book(title: String!): Book
となっていたやつです。
import { books } from './graphql/data' const rootResolver: RootResolver = (c) => { return { book: ({ title }: { title: string }) => { return books.find((book) => book.title === title) ?? null; }, }; };
このように書けます。
GraphQL クエリの引数がリゾルバ関数の第一引数に対応します。title
という引数名で string 型の値が渡されることを期待するので型を { title: string }
とします。
これにて引数を取るフィールドができました。GraphiQL でも応答が返っているのが見えます。
ところが author
フィールドが null になっていますね。
それもそのはず、リゾルバが返す値に author
フィールドは無いからです。
export const books: Book[] = [ { id: 0, title: "吾輩は猫である", authorId: 0 }, { id: 1, title: "こころ", authorId: 0 }, // ... ]; rootResolver().book({ title: "こころ" }); // => { id: 1, title: "こころ", authorId: 0 }
author
フィールドをちゃんと返すために、リゾルバから値を返すときにフィールドを埋めてあげる必要があります。
import { books } from './graphql/data' const rootResolver: RootResolver = (c) => { return { book: ({ title }: { title: string }) => { const book = books.find((book) => book.title === title); if (!book) return null; return { ...book, author: authors.find((author) => author.id === book.authorId) ?? null, }; }, }; };
...はい、言いたいことは分かります。
API へのリクエストで必ず author
が要求されるとは限らないのに、常に返り値に含めておくのは非効率です。型同士が相互に参照していたら無限に大きなオブジェクトを返す必要が出てきます。
このような課題に対して GraphQL では Field Resolver で対処するのが普通です。Query に対してのリゾルバではなく (今回の例でいうと) Book 型や Author 型に対するリゾルバを定義してフィールドの値を返せるようにする。
const BookResolvers = { author(parent: Book): Author | null { if (book.authorId == null) return null; return authors.find((author) => author.id === book.authorId) ?? null; }, };
こんな感じです。GraphQL で Book.author
が必要になったら TypeScript 側で BookResolvers.author(book)
が呼ばれてほしい。
さて公式のサンプルコードにある RootResolver を使って Field Resolver がどのように実装できるか気になります。調べると、どうやらRootResolver ではダメみたいです。ということで次の節で発展的な例を見ていきましょう。
とりあえずここまでの例を俯瞰するとこんな感じ。
import { type RootResolver, graphqlServer } from "@hono/graphql-server"; import { buildSchema } from "graphql"; import { Hono } from "hono"; import { authors, books } from "./graphql/data.js"; const typeDefs = buildSchema(` type Author { id: Int! name: String! firstName: String! lastName: String! books(findTitle: String): [Book!]! } type Book { id: Int! title: String! author: Author } type Query { hello(name: String): String! book(title: String!): Book books: [Book!]! } `); const rootResolver: RootResolver = (c) => { return { hello: ({ name = "Hono" }: { name: string }) => `Hello, ${name}!`, book: ({ title }: { title: string }) => { const book = books.find((book) => book.title === title); if (!book) return null; return { ...book, author: authors.find((author) => author.id === book.authorId) ?? null, }; }, books: () => { return books.map((book) => ({ ...book, author: authors.find((author) => author.id === book.authorId) ?? null, })); }, }; }; const app = new Hono(); app.get("/", (c) => c.redirect("/graphql")); app.use( "/graphql", graphqlServer({ schema: typeDefs, rootResolver, graphiql: true, }), ); export default app;
さらに発展的な例: Field Resolver
またまた結果だけ先に見たい人のために完成品のコードがここにあります。
公式のサンプルコードでは「リゾルバを定義するには RootResolver を使えよ」という雰囲気を感じるのですが、どうやら RootResolver で定義できるのは Query 型のフィールドに対するリゾルバのみのようです。(だから名前が "Root" Resolver)
では Query 型以外に対するリゾルバはどのように定義できるかというと、RootResolver を辞めればできます。
import { makeExecutableSchema } from '@graphql-tools/schema'; import { buildSchema } from 'graphql'; const typeDefs = buildSchema(` type Author { id: Int! name: String! firstName: String! lastName: String! books(findTitle: String): [Book!]! } type Book { id: Int! title: String! author: Author } type Query { hello(name: String): String! book(title: String!): Book books: [Book!]! } `); const resolvers = { Query: { book(_parent: unknown, { title }: { title: string }): Book | null { return books.find((book) => book.title === title) ?? null; }, }, Book: { author(book: Book): Author | null { if (book.authorId == null) return null; return authors.find((author) => author.id === book.authorId) ?? null; }, } }; const schema = makeExecutableSchema({ typeDefs, resolvers, }); const app = new Hono(); app.use( '/graphql', graphqlServer({ schema, graphiql: true, }), );
はい、RootResolver を辞めて resolvers という変数にリゾルバ関数を定義しました。
それを const schema = makeExecutableSchema({ typeDefs, resolvers })
で schema としてまとめてから graphqlServer({
schema })
に渡しています。
実際にクエリを叩いてみて、正しく Field Resolver が呼び出されているのが分かります。
リゾルバ関数の引数が変わっていることにも注目して見ましょう。
- 第一引数: parent オブジェクト
- 第二引数: フィールドの引数
- 第三引数: GraphQL Context
となります。
たとえば Author.books(findTitle: String): [Book!]!
のリゾルバの例、
const AuthorResolvers = { books( parent: Author, { findTitle }: { findTitle?: string }, context: Context, ): Book[] { console.info({ context}); const filtered = books.filter((book) => book.authorId === parent.id); return findTitle ? filtered.filter((post) => post.title.includes(findTitle)) : filtered; }, };
第一引数には親である Author 型のオブジェクトが渡されます。第二引数は GrpahQL クエリの引数ですね。
第三引数は Context です。Hono から import type { Context } from 'hono'
で import できる型のオブジェクトが渡されます。
私の書いたサンプルでは他にも「schema を別ファイルに切り出す」「リゾルバも別ファイルで定義したものを import する」などの例を見せています。
おまけ: Hono GraphQL Server Middleware の実装を覗いてみる
Hono GraphQL Server Middleware の実装はたったの2ファイルしかなくてとてもシンプルです。よって読み解くのも難しくない。
例えばクエリの実行はこのへん packages/graphql-server/src/index.ts に実装があります。
import { execute } from 'graphql' import type { FormattedExecutionResult } from 'graphql' // ... let result: FormattedExecutionResult const { rootResolver } = options try { result = await execute({ schema, document: documentAST, rootValue: rootResolver ? await rootResolver(c) : null, contextValue: c, variableValues: variables, operationName: operationName, }) } catch (contextError: unknown) { // ... }
というわけでクエリ実行に関しては実装者が定義した schema
, rootResolver
などをほぼそのまま graphql-js の execute()
に渡しているだけです。そのため私が今回のサンプルを書くにあたっても Hono に関する知識よりも graphql-js の知識を要する場面が多かったです。
まとめ
Hono GraphQL Server Middleware を触ってみました。うーんシンプル。
私からは以上です。