Hono GraphQL¶
Node.js GraphQL Validation Schema-first
Hono にマウントされた動作する GraphQL Yoga サーバーで、libsql + Drizzle ORM 経由で SQLite に接続される。ウィザードはエンドツーエンドの Users CRUD 例 — SDL、リゾルバ、テーブル、内蔵の GraphiQL IDE — を生成するため、pnpm dev を実行した瞬間に http://localhost:4000/graphql を開いてミューテーションを実行できる。
このテンプレートは 設計として schema-first である: 契約は src/schema.graphql (SDL) に存在し、src/GraphqlSchema.res の typeDefs テンプレート文字列にミラーされる。リゾルバは src/Resolvers.res 内の素の ReScript 関数で、名前で rootValue に配線される。このスタイルから卒業する場合、README は code-first ビルダー (Pothos、Nexus、gqtx) と対比して移行判断の材料を提供する。
生成内容¶
my-project/
├── rescript.json
├── package.json
├── drizzle.config.ts # drizzle-kit config (reads Schema.res.mjs)
├── vitest.config.mjs # registers vitest.setup.mjs
├── vitest.setup.mjs # pins DATABASE_URL=:memory: before module load
├── src/
│ ├── ServerMain.res # entry — calls Server.start()
│ ├── Server.res # Hono app, mounts yoga at /graphql
│ ├── GraphqlSchema.res # typeDefs + rootValue consumed by yoga
│ ├── Resolvers.res # module Users { listUsers / userById / createUser / deleteUser }
│ ├── Schema.res # Drizzle SQLite tables (users)
│ ├── Db.res # libsql client + Drizzle helpers
│ ├── Yoga.res # graphql-yoga bindings
│ ├── Hono.res # Hono bindings
│ ├── HonoNodeServer.res # @hono/node-server bindings
│ ├── Validation.res # zod or sury — selected in the wizard
│ ├── schema.graphql # human-authored SDL (mirror of typeDefs)
│ └── __tests__/Server.test.mjs # vitest hits app.request("/health")
├── README.md # Try It / Schema / Database / Project Layout
├── LICENSE # MIT, holder = project name
├── .env.example # documents DATABASE_URL
├── .nvmrc # Node 24
├── .gitignore # node_modules, data/, docs/schema.md, drizzle/, .env
├── .editorconfig # 2-space indent, LF line endings
└── .github/
├── dependabot.yml # weekly npm updates
└── workflows/ci.yml # install + rescript build + vitest
ウィザードオプション¶
オプション |
効果 |
|---|---|
Project name |
npm の |
Package manager |
npm / pnpm / yarn / bun。 |
Validation library |
|
主要な依存¶
パッケージ |
用途 |
バージョン |
|---|---|---|
|
ReScript コンパイラ |
|
|
標準ライブラリ |
|
|
コンパイル済み |
|
|
yoga をホストする HTTP ルーター |
|
|
Hono の Node アダプター |
|
|
リファレンス実装 (yoga の peer) |
|
|
スキーマ実行系 + GraphiQL UI |
|
|
SQLite ドライバ (Turso の |
|
|
型安全な SQL ビルダー |
|
|
ミューテーション入力のバリデーション (ウィザードで選択) |
|
|
マイグレーションのジェネレータ/ランナー |
|
|
|
|
|
スモークテストランナー |
|
|
|
|
主要なファイル¶
src/schema.graphql¶
人間が記述する信頼できる情報源。@graphql-markdown/cli (および Apollo Studio、Hasura、codegen など任意の外部 GraphQL ツール) が docs/schema.md のレンダリングのために読み取る:
type User {
id: Int!
name: String!
email: String!
}
type Query {
users: [User!]!
user(id: Int!): User
}
type Mutation {
createUser(name: String!, email: String!): User!
deleteUser(id: Int!): Boolean!
}
src/GraphqlSchema.res¶
yoga が実行するランタイムスキーマを構築する。typeDefs テンプレート文字列は src/schema.graphql をミラーする必要がある — SDL ファイルはツールチェインが読むが、yoga はインライン文字列を消費するため、バンドルは自己完結したままになる:
let typeDefs = `
type User { id: Int!, name: String!, email: String! }
type Query { users: [User!]!, user(id: Int!): User }
type Mutation {
createUser(name: String!, email: String!): User!
deleteUser(id: Int!): Boolean!
}
`
let schema = Yoga.buildSchema(typeDefs)
let rootValue: {..} = Obj.magic({
"users": Resolvers.Users.listUsers,
"user": Resolvers.Users.userById,
"createUser": Resolvers.Users.createUser,
"deleteUser": Resolvers.Users.deleteUser,
})
Obj.magic は意図的なものである — 多相的な (parent, args, ctx, info) => 'a のリゾルバ形状を消去することでレコードを汎化できるようにする。具体的なシグネチャは Resolvers.res 内の各リゾルバ定義箇所で検査される。
src/Resolvers.res¶
各 GraphQL 型はネストモジュールに配置され、GraphqlSchema.res から Resolvers.<Type>.<field> としてフィールドを参照できる。同梱の Users モジュールは完全な CRUD 表層を示している:
module Users = {
let listUsers = async (_parent, _args, _ctx, _info) => ...
let userById = async (_parent, args, _ctx, _info) => ...
let createUser = async (_parent, args, _ctx, _info) => {
switch Validation.parseCreateUserInput(args->Obj.magic) {
| Error(msg) => failwith(msg) // surfaced as a GraphQL error
| Ok(payload) => ... // Drizzle insert + returning
}
}
let deleteUser = async (_parent, args, _ctx, _info) => ...
}
createUser はすべての入力を Validation.parseCreateUserInput に通し、エラー時に failwith する — graphql-yoga が throw を捕捉し、レスポンスの errors[] エントリに変換する。追加の配線は不要である。
src/Server.res¶
Hono を配線し、yoga を /graphql にマウントし (GraphiQL のため GET + POST 両方)、start() を別途公開することで、vitest がポートをバインドせずに import("../Server.res.mjs") できるようにする:
let yoga = Yoga.createYoga({
"schema": GraphqlSchema.schema,
"rootValue": GraphqlSchema.rootValue,
})
let app = Hono.createApp()
app->Hono.onError((err, ctx) => {
Console.error(err)
ctx->Hono.status(500)->Hono.json({"error": "Internal Server Error"})
})
app->Hono.get("/health", ctx => ctx->Hono.json({"status": "ok"}))
app->Hono.get("/graphql", handleYoga)
app->Hono.post("/graphql", handleYoga)
let start = () => {
HonoNodeServer.serve({fetch: app->HonoNodeServer.honoFetch, port: 4000})
Console.log("Server on http://localhost:4000 — GraphiQL at /graphql")
}
src/ServerMain.res¶
npm の start / dev スクリプトが実行する 2 行のエントリポイント:
Server.start()
Server.res から分離されており、app を副作用なくテストからインポートできる状態に保つ。
src/Schema.res¶
db:generate が消費する Drizzle の SQLite テーブル定義:
let users = sqliteTable("users", {
"id": intCol("id", {"primaryKey": true, "autoIncrement": true}),
"name": textCol("name", {"notNull": true}),
"email": textCol("email", {"notNull": true}),
})
drizzle.config.ts¶
drizzle-kit を コンパイル済み スキーマ (Schema.res.mjs) に向け、DATABASE_URL を環境変数から読み取り、既定値は file:./data/app.db とする:
export default defineConfig({
schema: "./src/Schema.res.mjs",
out: "./drizzle",
dialect: "sqlite",
dbCredentials: {
url: process.env.DATABASE_URL ?? "file:./data/app.db",
},
});
vitest.config.mjs + vitest.setup.mjs¶
セットアップファイルは、いずれかのモジュールがロードされる前に DATABASE_URL を libsql のインメモリストアに固定する:
process.env.DATABASE_URL = ":memory:";
これが重要なのは、Server.res.mjs のインポートが推移的に Db → Schema → Resolvers をロードし、上書きしなければインポートチェインがテスト収集時に ./data/app.db を開こうとしてしまうためである。
src/Validation.res¶
両バリアントは parseCreateUserInput: JSON.t => result<createUserInput, string> を公開する。createUser リゾルバは Error で failwith し、graphql-yoga がそれを GraphQL エラーに変換する。
npm スクリプト¶
スクリプト |
説明 |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
新しい型とリゾルバの追加¶
README で詳細に解説しているが、ワークフローは 6 ステップに集約される。本テンプレート同梱の Users 型をコピー可能な作例として活用してほしい。
src/schema.graphqlを編集して、新しい型とルートフィールドを追加する:type Post { id: Int!, title: String!, authorId: Int! } extend type Query { posts: [Post!]! }
src/GraphqlSchema.res#typeDefsでミラーする ことで、ランタイムスキーマと SDL を同期させる。Drizzle テーブルを追加 する:
src/Schema.resに追加し、pnpm db:generateとpnpm db:migrateを実行する。リゾルバモジュールを追加 する:
src/Resolvers.resに追加する:module Posts = { let listPosts = async (_p, _a, _c, _i) => await Db.db ->Db.select({"id": Schema.posts["id"], "title": Schema.posts["title"], "authorId": Schema.posts["authorId"]}) ->Db.from(Schema.posts) ->Db.allAsync }
rootValueに配線 する:GraphqlSchema.resで配線する:let rootValue: {..} = Obj.magic({ // existing user resolvers... "posts": Resolvers.Posts.listPosts, })
ドキュメントを再生成:
pnpm docs:graphqlでdocs/schema.mdを書き出す。
ミューテーションも同じパターンに従うが、追加で 1 ステップある: Validation.res に parsePostInput を追加し、Error で failwith することで、graphql-yoga が throw を GraphQL エラーに変換できるようにする。
リゾルバを単独でテストする¶
src/__tests__/Server.test.mjs は /health ルートに対する HTTP 境界をカバーする。リゾルバ単位のテストでは、graphql/execution でスキーマを直接呼び出す:
import { graphql } from "graphql";
import { schema, rootValue } from "../GraphqlSchema.res.mjs";
it("listUsers returns rows from Db.allAsync", async () => {
const result = await graphql({
schema,
rootValue,
source: `query { users { id name } }`,
});
expect(result.errors).toBeUndefined();
expect(Array.isArray(result.data.users)).toBe(true);
});
純粋なユニットテストでは、vi.mock("../Db.res.mjs", ...) で Db.allAsync を モックすることで、SQLite を起動せずにリゾルバロジックを実行できる。vitest.setup.mjs のインメモリ DATABASE_URL の固定と組み合わせれば、ファイルシステム I/O なしでスイート全体を実行できる。
2日目以降のレシピ¶
GraphQL リゾルバの追加 — 新しい型とリゾルバを追加する。SDL/typeDefs の同期ワークフローも含む
Drizzle のセットアップ — Drizzle スキーマ、マイグレーション、Turso へのスワップ
Hono エンドポイントの追加 —
/graphqlと並行して REST ルートを追加する (例: webhook 受信、ヘルスプローブ)OpenAPI ドキュメントの追加 — 並行する REST 表層を成長させた場合に OpenAPI を上乗せする
プロジェクトを開いた後の ReScript 側のエディタワークフローについては、機能概要 を参照してほしい。
補足¶
SDL と typeDefs は一致している必要がある。 SDL ファイル (
src/schema.graphql) はgraphql-markdownや外部ツールが読み取るものであり、GraphqlSchema.resのtypeDefsは yoga が実行するものである。これらは意図的に 2 つのコピーである — バンドルが自己完結するため — が、容易にずれてしまう。pre-commit フックでnode -e "require('graphql').buildSchema(require('fs').readFileSync('src/schema.graphql','utf-8'))"を実行すれば、CI より先に SDL のパースエラーを検出できる。Schema-first と code-first。 このテンプレートは schema-first を志向している。これは多言語チーム、公開スキーマ、外部コード生成と相性がよいためである。コントリビュータが多くスキーマの変更が激しい場合、README の Schema セクションは
GraphqlSchema.resを code-first ビルダー (Pothos、Nexus) に差し替える方法を解説している。Drizzle と Hono はそのまま残り、変わるのはスキーマ/リゾルバの配線のみである。リゾルバ単位のテスト。
src/__tests__/Server.test.mjsは HTTP 境界 (app.request("/health")) のみをカバーする。README の Schema セクションでは、テストからgraphql({ schema, rootValue, source })を直接呼び出す方法を示している — SQLite を起動せずにリゾルバロジックを実行したい場合に有用である。純粋なユニットテストではvi.mock("../Db.res.mjs", ...)と組み合わせるとよい。Server.resはモジュールトップレベルでserve()を呼び出さない — その処理はstart()に置かれ、ServerMain.resから呼び出される。これにより、テストはポート 4000 をバインドせずにimport("../Server.res.mjs")できる。リゾルバ内の
failwithは GraphQL エラーになる。 graphql-yoga は throw を捕捉してパスをたどり、500 ではなくerrors[]エントリを発行する。クライアントから見えるバリデーションにはfailwithを使い、Hono.onErrorは yoga 以外のルートでの想定外の例外用に温存する (グローバルハンドラは JSON 500 を返す)。ローカルの SQLite ファイルが既定。
DATABASE_URL=file:./data/app.dbが標準の配線である。コードに触れずにlibsql://URL (Turso) に差し替えできる — libsql クライアントは両方のプロトコルを話す。.env.exampleがこの変数を記載している。Node 24 のみ対応。
engines.nodeは>=24、.nvmrcは24を指定している。これより古いメジャーバージョンは CI で検証していない。生成物は gitignore 済み:
data/、drizzle/(マイグレーション SQL)、docs/schema.md、.env。