👊

見よ、これがHonoのRPCだ

2024/05/09に公開1

僕が開発しているWebフレームワークHonoは、同じJavaScriptのフレームワーク、Expressと比べられることが多いです。どちらもやれることはほぼ同じですが、HonoのアドバンテージはファーストクラスでTypeScriptをサポートしていることです。特に「RPC」機能は他のフレームワークにはなかった「TypeScriptの型でサーバーとクライアントの仕様を共有する」ことを可能にしています。今回はそのHonoのRPCについて紹介します。

どんなものか

まず、どんなものかを箇条書きで共有します。

  • Web APIの仕様、特にインプット・アウトプットをサーバーとクライアント間で共有するためのもの
  • OpenAPIやgRPCを使ってやりたかったことを叶えるかもしれない
  • サーバーとクライアントをどちらもTypeScriptで書くことが大前提である
  • 同種のものにtRPCがあるが、Honoの場合、普通のREST APIを書くだけで使える
  • クライアントはfetchのラッパーであり、スタンダードなResponseオブジェクトを扱う
  • いわゆる「型安全」を提供するものであり、エディタの補完がバチバチに効く

デモ

見てもらうのが手っ取り早いので見てもらいましょう。

サーバー

まずはサーバーサイドでAPIを書きます。ユーザー情報を扱うエンドポイントです。string型のnamenumber型のageを受け取りたいので、それをZodを使って定義しています。リクエストのJSONボディを検証したいので、jsonを指定してバリデータミドルウェアにスキーマを渡しています。ハンドラの中ではc.req.valid()を使って検証済みの値を型付きで取得できます。c.json()string型のmessageを返却しています。

Server

クライアント

次にクライアントです。サーバーからexportされたAppTypeという型をimportしています。それをhcという関数にジェネリクスとして渡し、クライアントオブジェクトを作成します。すると、APIのエンドポイントのパスとメソッド情報がclient.api.users.$postのように補完されます。しかも、クライアントが送るべきボディがJSONであり、中身がnameageであることも型として表現されています。resは標準のResponseオブジェクトですが、res.json()でJSONオブジェクトを取り出すとそれにも型がついていてstringmessageを持つことが分かります。

Client

RPCを作る

より詳しくRPCの作り方を見ていきましょう。

普通のREST APIを書く

HonoのRPCは「普通の」REST APIを書けばそれだけでRPCに対応します。まず超簡単なAPIを作ってみます。以下は/api/usersでPOSTリクエストを受け取り、message型をキーにとる値を含んだJSONレスポンスを返しています。

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

型を作って共有する

次に型を作ります。といっても簡単です。app.post()の返却値を取っておき、typeofをすればよいです。今回はそれをそのままAppTypeという名前でexportしておきます。

server.ts
// routesを定義する
const routes = app.post('/api/users', (c) => {
  return c.json({
    message: `young man is 20 years old`
  })
})

// routesの型を取り、exportしておく
export type AppType = typeof routes

hcでクライアントを作る

クライアントを書きます。今回はCLIから叩かれるスクリプトを想定し、最低限の実装にします。

まずサーバーからexportされたAppTypeという型をimportします。これが実体ではなく「型」であることがポイントです。そしてそれをhcという関数にジェネリクスで渡します。これでクライアントオブジェクトができます。

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

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

リクエストをする

ここまでくれば、エンドポイントのパスとメソッドが補完されます。

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

path

レスポンスを扱う

resはWeb標準のResponseオブジェクトになっています。なので、res.okが使えます。ただし、res.json()で取得した値には型が付きます。サーバーからはstring型のmessageというフィールドが返却されているので、クライアントでもdata.messagestringになります。

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

Zodでバリデートする

上記はサーバーがただレスポンスを返すだけでした。次は、クライアントがデータを送り、それをサーバーが検証(Validation)して中身を扱うようにしてみます。

いくつかのValidatorに対応していますが、今回はZodを使います。スキーマを定義しましょう。

server.ts
import { z } from 'zod'

// ...

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

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

ハンドラの中では、c.req.valid()メソッドを使うと検証済みのデータを型付きで取得できます。

schema

今回はそれをそのままテキストに展開して返しているだけですが、ロジックもしくはロジックへ渡す処理をこの中に書けます。

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

サーバーがデータを受け取れる準備ができたら、クライアントから値を送れるようになります。エンドポイントを表すclient.api.users.$post()の引数にフォーマットがJSONであること、そしてその中身を指定します。

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

サーバーで定義したスキーマ通りに型が付いているのが分かるでしょう。なので、例えばage'20'といった文字列にしたら、エディタ上で赤い波線がついてエラーがでます。

request body

他のバリデータを使う

先ほどはバリデータにZodを使いましたが、どんなバリデータでも使えます。特に以下のものはHonoのミドルウェアが提供されているのですぐ使えます。

例えば、Valibotを使う場合はこのように書けます。バリデータとHonoから提供されているバリデータミドルウェアを変更するだけで、他は同じで、同じように型が付きます。

server.ts
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()の第2引数にステータスコードを明示的に指定すると、クライアントではステータスコードに基づいて型が自動的に選択されます。

例えば、URLパラメータでidを受け取ってuserを検索、なければ404errorというプロパティが入ったJSON、あれば200userを返却します。

server.ts
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の時はJSONの中身が{user:User}res.status === 404の時には{error:string}となります。

client.mts
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のJSXはReactの一部のフックと互換があるのでhonoパッケージだけで書けます。

client.tsx
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)

これはどこに置いてもいいのですが、例えば、ひとつのHonoのサーバーアプリでRPCに対応させつつ、WebページとAPIを配信するということができます。

server.tsx
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

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

興味深いのは、Next.jsやSveleKitなどフルスタックフレームワークの中で使われています。APIルートをHonoのサーバーで書いて、型を共有。UIの部分でhcで作ったクライアントを使う。例えば、Next.js内での例はこちらです。

https://zenn.dev/chot/articles/e109287414eb8c

テストでの活用

HonoにはTestingヘルパーがあります。これを使うとhcで作ったクライアントと同じように、型安全でありながら、実データのやり取りもできます。ですので、返ってきた値resを確認することでサーバーアプリが正しく挙動しているかをテストできます。Webのオブジェクトが抽象化されているので、ポートをあけて実サーバーを立ち上げずとも、試験できます。

server.test.ts
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とViteのメタフレームワーク「HonoX」の中で使うとより効果的です。

https://github.com/honojs/honox

これについてはまた別の機会で紹介します。

Zod OpenAPI

それでも、OpenAPIのドキュメントを吐きたい!という場合は、Zod OpenAPIというHonoのラッパーがあります。これを使うとこれまで見てきた、型安全のメリットを享受しつつ、OpenAPIのドキュメントを生やせます。

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

まとめ

以上、Honoの推し機能のひとつ「RPC」について紹介しました。おさらいすると以下でRPCを体験できます。

  • HonoでREST APIを書く
  • 型を共有する
  • hcに渡してクライアントを作る
  • エンドポイントとリクエストボディが補完される
  • レスポンスのJSONの中身に型が付いている
  • ステータスコードで分岐できる

「サーバーとクライアントの仕様の共有」という永遠のテーマをTypeScriptの型を使って「カジュアル」に解決しているのが特筆すべき点です。使える場面がある型はぜひ使ってみてください。

https://hono.dev/guides/rpc

Discussion