見よ、これがHonoのRPCだ
僕が開発しているWebフレームワークHonoは、同じJavaScriptのフレームワーク、Expressと比べられることが多いです。どちらもやれることはほぼ同じですが、HonoのアドバンテージはファーストクラスでTypeScriptをサポートしていることです。特に「RPC」機能は他のフレームワークにはなかった「TypeScriptの型でサーバーとクライアントの仕様を共有する」ことを可能にしています。今回はそのHonoのRPCについて紹介します。
どんなものか
まず、どんなものかを箇条書きで共有します。
- Web APIの仕様、特にインプット・アウトプットをサーバーとクライアント間で共有するためのもの
- OpenAPIやgRPCを使ってやりたかったことを叶えるかもしれない
- サーバーとクライアントをどちらもTypeScriptで書くことが大前提である
- 同種のものにtRPCがあるが、Honoの場合、普通のREST APIを書くだけで使える
- クライアントは
fetch
のラッパーであり、スタンダードなResponse
オブジェクトを扱う - いわゆる「型安全」を提供するものであり、エディタの補完がバチバチに効く
デモ
見てもらうのが手っ取り早いので見てもらいましょう。
サーバー
まずはサーバーサイドでAPIを書きます。ユーザー情報を扱うエンドポイントです。string
型のname
、number
型のage
を受け取りたいので、それをZodを使って定義しています。リクエストのJSONボディを検証したいので、json
を指定してバリデータミドルウェアにスキーマを渡しています。ハンドラの中ではc.req.valid()
を使って検証済みの値を型付きで取得できます。c.json()
でstring
型のmessage
を返却しています。
クライアント
次にクライアントです。サーバーからexportされたAppType
という型をimportしています。それをhc
という関数にジェネリクスとして渡し、クライアントオブジェクトを作成します。すると、APIのエンドポイントのパスとメソッド情報がclient.api.users.$post
のように補完されます。しかも、クライアントが送るべきボディがJSONであり、中身がname
とage
であることも型として表現されています。res
は標準のResponse
オブジェクトですが、res.json()
でJSONオブジェクトを取り出すとそれにも型がついていてstring
のmessage
を持つことが分かります。
RPCを作る
より詳しくRPCの作り方を見ていきましょう。
普通のREST APIを書く
HonoのRPCは「普通の」REST APIを書けばそれだけでRPCに対応します。まず超簡単な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
しておきます。
// 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
という関数にジェネリクスで渡します。これでクライアントオブジェクトができます。
import type { AppType } from './server'
import { hc } from 'hono/client'
const client = hc<AppType>('/')
リクエストをする
ここまでくれば、エンドポイントのパスとメソッドが補完されます。
const res = await client.api.users.$post()
レスポンスを扱う
res
はWeb標準のResponse
オブジェクトになっています。なので、res.ok
が使えます。ただし、res.json()
で取得した値には型が付きます。サーバーからはstring
型のmessage
というフィールドが返却されているので、クライアントでもdata.message
がstring
になります。
if (res.ok) {
const data = await res.json()
console.log(data.message)
}
Zodでバリデートする
上記はサーバーがただレスポンスを返すだけでした。次は、クライアントがデータを送り、それをサーバーが検証(Validation)して中身を扱うようにしてみます。
いくつかのValidatorに対応していますが、今回はZodを使います。スキーマを定義しましょう。
import { z } from 'zod'
// ...
const schema = z.object({
name: z.string(),
age: z.number()
})
「どんなデータを受け取りたいか?」を考え、それをそのままスキーマに落とし込めばいいでしょう。
ハンドラの中では、c.req.valid()
メソッドを使うと検証済みのデータを型付きで取得できます。
今回はそれをそのままテキストに展開して返しているだけですが、ロジックもしくはロジックへ渡す処理をこの中に書けます。
クライアントからデータを送る
サーバーがデータを受け取れる準備ができたら、クライアントから値を送れるようになります。エンドポイントを表すclient.api.users.$post()
の引数にフォーマットがJSONであること、そしてその中身を指定します。
const res = await client.api.users.$post({
'json': {
'name': 'young man',
'age': 20
}
})
サーバーで定義したスキーマ通りに型が付いているのが分かるでしょう。なので、例えばage
を'20'
といった文字列にしたら、エディタ上で赤い波線がついてエラーがでます。
他のバリデータを使う
先ほどはバリデータに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()
の第2引数にステータスコードを明示的に指定すると、クライアントではステータスコードに基づいて型が自動的に選択されます。
例えば、URLパラメータでid
を受け取ってuser
を検索、なければ404
でerror
というプロパティが入ったJSON、あれば200
でuser
を返却します。
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}
となります。
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()
の返り値の型が変わっています。
利用シーン
これまでクライアントは最低限の実装だけでしたが、いくつかのユースケースがあります。
フロントエンド
これまではクライアント部分だけの実装だけでしたが、フロントエンドと組み合わせるとこうなります。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)
これはどこに置いてもいいのですが、例えば、ひとつのHonoのサーバーアプリでRPCに対応させつつ、WebページとAPIを配信するということができます。
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内での例はこちらです。
テストでの活用
HonoにはTestingヘルパーがあります。これを使うと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とViteのメタフレームワーク「HonoX」の中で使うとより効果的です。
これについてはまた別の機会で紹介します。
Zod OpenAPI
それでも、OpenAPIのドキュメントを吐きたい!という場合は、Zod OpenAPIというHonoのラッパーがあります。これを使うとこれまで見てきた、型安全のメリットを享受しつつ、OpenAPIのドキュメントを生やせます。
まとめ
以上、Honoの推し機能のひとつ「RPC」について紹介しました。おさらいすると以下でRPCを体験できます。
- HonoでREST APIを書く
- 型を共有する
-
hc
に渡してクライアントを作る - エンドポイントとリクエストボディが補完される
- レスポンスのJSONの中身に型が付いている
- ステータスコードで分岐できる
「サーバーとクライアントの仕様の共有」という永遠のテーマをTypeScriptの型を使って「カジュアル」に解決しているのが特筆すべき点です。使える場面がある型はぜひ使ってみてください。
Discussion
素敵🥰
(今絶賛使ってみてます!)