Tags English

Blog - Yusuke Wada

Screenshot

Hey, this is Hono's RPC

#Hono

この記事は英語で書かれた原文をAIが翻訳したものです。

Hey, this is Hono's RPC

WebフレームワークのHonoは、もうひとつの人気JavaScriptフレームワークであるExpressとよく比較されます。どちらも似たようなことができますが、Honoにはアドバンテージがあります。TypeScriptをファーストクラスでサポートしていることです。Honoのユニークな機能のひとつが「RPC」で、TypeScriptの型を使ってサーバーとクライアントの間で仕様を共有できます。これは他のフレームワークにはあまりない機能です。この記事では、HonoのRPC機能を紹介します。

HonoのRPCとは?

まず、HonoのRPCがどういうものかをまとめてみましょう。

デモ

百聞は一見にしかず。まずは見てみましょう。

サーバー

まず、サーバー側でAPIを書きます。ユーザー情報を扱うエンドポイントを作りましょう。string型のnamenumber型のageを受け取ることをZodで定義します。リクエストのJSONボディをバリデーションするため、jsonを指定してバリデーションミドルウェアにスキーマを渡します。ハンドラの中ではc.req.valid()でバリデーション済みの値を型付きで取得します。c.json()string型のmessageを持つレスポンスを返します。

Server

クライアント

次にクライアント側です。サーバーからexportされたAppTypeという型をimportします。この型をhc関数にジェネリクスとして渡してクライアントオブジェクトを作ります。すると、client.api.users.$postのように、APIエンドポイントのパスとメソッドが補完されるようになります。クライアントは、リクエストボディがnameageフィールドを持つJSONであるべきことも知っています。resは標準のResponseオブジェクトですが、res.json()を呼ぶと型付きのオブジェクトが返ってきて、messagestringであることが分かります。

Client

RPCを作る

HonoでRPCを作る方法をもう少し詳しく見ていきましょう。

シンプルなREST APIを書く

HonoのRPC機能は「普通の」REST APIで動きます。まずはシンプルなAPIを作りましょう。次のコードは/api/usersへのPOSTリクエストを受け付け、messageフィールドを持つJSONレスポンスを返します。

app.post('/api/users', (c) => {
  return c.json({
    message: `young man is 20 years old`
  })
})

型を作って共有する

次に型を作って共有します。簡単です。app.post()の戻り値を受け取って、typeofで型を取得するだけ。これをAppTypeとしてexportします。

// Define routes
const routes = app.post('/api/users', (c) => {
  return c.json({
    message: `young man is 20 years old`
  })
})

// Get the type of the routes and export it
export type AppType = typeof routes

hcでクライアントを作る

ではクライアントを書きましょう。コマンドラインから実行するスクリプトを想定して、実装は最小限にします。

まず、サーバーからexportされたAppType型をimportします。重要なのは、これが「型」であって実際のコードではないことです。そして、この型をhc関数にジェネリクスとして渡します。これでクライアントオブジェクトができます。

import type { AppType } from './server'
import { hc } from 'hono/client'

const client = hc<AppType>('/')

リクエストを送る

これでエンドポイントのパスとメソッドが補完されるようになります。

const res = await client.api.users.$post()

path

レスポンスを扱う

resは標準のWeb Responseオブジェクトなので、res.okが使えます。ただし、res.json()を呼んでJSONオブジェクトを取得すると、それには型が付いています。サーバーがstring型のmessageフィールドを返すので、クライアントはdata.messagestringであることを知っています。

if (res.ok) {
  const data = await res.json()
  console.log(data.message)
}

Zodでバリデーションする

先ほどの例では、サーバーはただレスポンスを返すだけでした。今度は、クライアントからデータを送り、サーバーがそれをバリデーションしてから処理するようにしましょう。

Honoはいくつかのバリデータをサポートしていますが、今回はZodを使います。スキーマを定義しましょう。

import { z } from 'zod'

// ...

const schema = z.object({
  name: z.string(),
  age: z.number()
})

どんなデータを受け取りたいかを考えて、それをそのままスキーマに落とし込めばOKです。

ハンドラではc.req.valid()メソッドを使って、バリデーション済みのデータを型付きで取得できます。

schema

この例ではバリデーション済みのデータをテキストメッセージに入れているだけですが、ロジックを含めたり、他のロジックにデータを渡したりすることもできます。

クライアントからデータを送る

サーバーがデータを受け取れるようになったので、クライアントから値を送れます。client.api.users.$post()メソッドに、JSONフォーマットの指定とデータを渡します。

const res = await client.api.users.$post({
  'json': {
    'name': 'young man',
    'age': 20
  }
})

型がサーバーで定義したスキーマと一致していることが分かります。たとえばage'20'のような文字列で送ろうとすると、エディタが赤い波線でエラーを示してくれます。

request body

他のバリデータを使う

先ほどはバリデータにZodを使いましたが、どのバリデータでも使えます。特に以下のバリデータはHonoのミドルウェアがサポートしていて、すぐに使えます。

たとえばValibotを使いたければ、次のように書けます。バリデータとHonoのバリデーションミドルウェアを変えるだけで、他はそのまま、型もちゃんと付きます。

import { number, object, string } from 'valibot'
import { vValidator } from '@hono/valibot-validator'

// ...

const schema = object({
  name: string(),
  age: number()
})

const routes = app.post('/api/users', vValidator('json', schema), (c) => {
  const data = c.req.valid('json')
  // ...
})

ステータスコードで分岐する

ステータスコードによってJSONレスポンスの型が変わることがあります。c.json()の第二引数でステータスコードを明示的に指定すると、クライアントはステータスコードに応じて自動的に型を選択してくれます。

たとえば、URLパラメータでidを受け取ってuserを検索し、見つからなければerrorプロパティを持つJSONと404を返し、見つかればuser200を返すとしましょう。

const schema = z.object({
  id: z.string()
})

const routes = app.get('/api/users/:id', zValidator('param', schema), (c) => {
  const { id } = c.req.valid('param')

  const user = findUser(id)

  if (!user) {
    return c.json(
      {
        error: 'not found'
      },
      404
    )
  }

  return c.json(
    {
      user
    },
    200
  )
})

クライアントのコードはこうです。resのステータスで分岐します。res.okがtrueのときはJSONの中身が{user:User}に、res.status === 404のときは{error:string}になります。

const res = await client.api.users[':id'].$get({
  param: {
    id: '123'
  }
})

if (res.ok) {
  const data200 = await res.json()
  console.log(`Get User: ${data200.user.name}`)
}

if (res.status === 404) {
  const data404 = await res.json()
  console.log(`Error: ${data404.error}`)
}

分岐によってres.json()の型が変わります。

status code

ユースケース

ここまでは最小限のクライアント実装だけを扱ってきましたが、HonoのRPC機能にはいくつかのユースケースがあります。

フロントエンド

フロントエンドと組み合わせるとこうなります。HonoのJSXはReactのフックの一部と互換性があるので、honoパッケージだけでこれを書けます。

import { render } from 'hono/jsx/dom'
import { useEffect, useState } from 'hono/jsx'
import { hc } from 'hono/client'
import { AppType } from '.'

function App() {
  const [message, setMessage] = useState('')

  const client = hc<AppType>('/')

  const fetchApi = async () => {
    const res = await client.api.users.$post({
      json: {
        name: 'young man',
        age: 20
      }
    })
    const data = await res.json()
    setMessage(data.message)
  }

  useEffect(() => {
    fetchApi()
  }, [])

  return <p>{message}</p>
}

const domNode = document.getElementById('root')!
render(<App />, domNode)

これはどこに置いてもいいのですが、たとえば、RPC対応のAPIとWebページの両方を配信する単一のHonoサーバーアプリを作ることもできます。

import { Hono } from 'hono'
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'

const app = new Hono()

app.get('/', (c) => {
  return c.html(
    <html>
      <head>
        <script type="module" src="/src/client.tsx"></script>
      </head>
      <body>
        <div id="root"></div>
      </body>
    </html>
  )
})

const schema = z.object({
  name: z.string(),
  age: z.number()
})

const routes = app.post('/api/users', zValidator('json', schema), (c) => {
  const data = c.req.valid('json')
  return c.json({
    message: `${data.name} is ${data.age.toString()} years old`
  })
})

export type AppType = typeof routes

export default app

フルスタックフレームワークの中で

面白いのは、HonoのRPCがNext.jsやSvelteKitのようなフルスタックフレームワークの中でも使えることです。APIルートをHonoサーバーで書き、型を共有して、hcで作ったクライアントをUI部分で使えます。

テストで使う

HonoにはTesting Helpersがあります。これを使うと、hcクライアントと同じような型安全で実データを使ったやり取りができます。つまり、返り値のresをチェックすることで、サーバーアプリが正しく動作しているかをテストできます。Webのオブジェクトは抽象化されているので、ポートを開いたり実際のサーバーを立てたりせずにテストできます。

import { testClient } from 'hono/testing'
import app from './server'

it('Should return 200 response', async () => {
  const client = testClient(app)
  const res = await client.api.users[':id'].$get({
    param: {
      id: '123'
    }
  })
  expect(res.status).toBe(200)
  expect(await res.json()).toEqual({ message: 'my id is 123' })
})

HonoX

HonoのRPC機能を、Viteベースの新しいメタフレームワーク「HonoX」と組み合わせて使うと、さらに強力になります。

https://github.com/honojs/honox

これについては今後の記事で詳しく紹介します。

Zod OpenAPI

それでもOpenAPIドキュメントを生成したい場合はどうすればいいのでしょうか?HonoにはZod OpenAPIというラッパーがあります。これを使えば、型安全の恩恵を受けながらOpenAPIドキュメントを生成できます。

https://github.com/honojs/middleware/tree/main/packages/zod-openapi

まとめ

Honoの目玉機能のひとつ「RPC」を見てきました。まとめると、HonoでのRPC体験はこうなります。

「サーバーとクライアントの仕様共有」という問題を、TypeScriptの型を使って「カジュアルに」解決できるのがHonoの注目すべき点です。ハマるシーンがあれば、ぜひ試してみてください。

https://hono.dev/guides/rpc