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.restypeDefs テンプレート文字列にミラーされる。リゾルバは 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 の name、ライセンス保有者、およびこのテンプレートをコピーして派生させる Worker プロジェクトでの wranglername フィールドになる

Package manager

npm / pnpm / yarn / bun。packageManager フィールド、README の install/run コマンド、CI のキャッシュキーに影響する

Validation library

zodsurysrc/Validation.res を選択し、対応する依存を追加する。両者は同じ parseCreateUserInput シグネチャを公開するため、リゾルバは分岐する必要がない

主要な依存

パッケージ

用途

バージョン

rescript

ReScript コンパイラ

TemplateVersions.RESCRIPT

@rescript/core

標準ライブラリ

TemplateVersions.RESCRIPT_CORE

@rescript/runtime

コンパイル済み .res.mjs がインポートするランタイムスタブ

TemplateVersions.RESCRIPT_RUNTIME

hono

yoga をホストする HTTP ルーター

TemplateVersions.HONO

@hono/node-server

Hono の Node アダプター

TemplateVersions.HONO_NODE_SERVER

graphql

リファレンス実装 (yoga の peer)

TemplateVersions.GRAPHQL

graphql-yoga

スキーマ実行系 + GraphiQL UI

TemplateVersions.GRAPHQL_YOGA

@libsql/client

SQLite ドライバ (Turso の libsql:// にも対応)

TemplateVersions.LIBSQL_CLIENT

drizzle-orm

型安全な SQL ビルダー

TemplateVersions.DRIZZLE_ORM

zod または sury

ミューテーション入力のバリデーション (ウィザードで選択)

TemplateVersions.ZOD / SURY

drizzle-kit (dev)

マイグレーションのジェネレータ/ランナー

TemplateVersions.DRIZZLE_KIT

@graphql-markdown/cli (dev)

pnpm docs:graphql 向けに SDL を Markdown にレンダリング

TemplateVersions.GRAPHQL_MARKDOWN

vitest (dev)

スモークテストランナー

TemplateVersions.VITEST

@vitest/coverage-v8 (dev)

test:coverage 向けのカバレッジプロバイダ

TemplateVersions.VITEST_COVERAGE_V8

主要なファイル

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 リゾルバは Errorfailwith し、graphql-yoga がそれを GraphQL エラーに変換する。

npm スクリプト

スクリプト

説明

start

node src/ServerMain.res.mjs — GraphQL サーバーを 1 回起動する

dev

node --watch src/ServerMain.res.mjs — 保存ごとに再起動する

test

vitest run — スモークスイートを実行する

test:coverage

vitest run --coverage — 同じだが v8 のカバレッジレポートを付与する

docs:graphql

graphql-markdown --schema=src/schema.graphql ... — SDL から docs/schema.md をレンダリングする

db:generate

drizzle-kit generateSchema.res からマイグレーション SQL を生成する

db:migrate

drizzle-kit migrate — 未適用のマイグレーションを SQLite ファイルに適用する

res:build

rescript — 単発のコンパイル

res:dev

rescript -w — 保存ごとに再コンパイル

res:clean

rescript clean — 生成された .res.mjs を削除する

新しい型とリゾルバの追加

README で詳細に解説しているが、ワークフローは 6 ステップに集約される。本テンプレート同梱の Users 型をコピー可能な作例として活用してほしい。

  1. src/schema.graphql を編集して、新しい型とルートフィールドを追加する:

    type Post { id: Int!, title: String!, authorId: Int! }
    extend type Query { posts: [Post!]! }
    
  2. src/GraphqlSchema.res#typeDefs でミラーする ことで、ランタイムスキーマと SDL を同期させる。

  3. Drizzle テーブルを追加 する: src/Schema.res に追加し、pnpm db:generatepnpm db:migrate を実行する。

  4. リゾルバモジュールを追加 する: 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
    }
    
  5. rootValue に配線 する: GraphqlSchema.res で配線する:

    let rootValue: {..} = Obj.magic({
      // existing user resolvers...
      "posts": Resolvers.Posts.listPosts,
    })
    
  6. ドキュメントを再生成: pnpm docs:graphqldocs/schema.md を書き出す。

ミューテーションも同じパターンに従うが、追加で 1 ステップある: Validation.resparsePostInput を追加し、Errorfailwith することで、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日目以降のレシピ

プロジェクトを開いた後の ReScript 側のエディタワークフローについては、機能概要 を参照してほしい。

補足

  • SDL と typeDefs は一致している必要がある。 SDL ファイル (src/schema.graphql) は graphql-markdown や外部ツールが読み取るものであり、GraphqlSchema.restypeDefs は 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.nvmrc24 を指定している。これより古いメジャーバージョンは CI で検証していない。

  • 生成物は gitignore 済み: data/drizzle/ (マイグレーション SQL)、docs/schema.md.env