tRPC の基本的な使い方と OpenAPI 連携
2024/10/22 追記
結構参照して頂いている方が多いみたいなのですが、この記事でおすすめしてる trpc-openapi が現状はあまりおすすめ出来ない状態になっているので、その注意点などについて説明します。
Any plans to seek a new maintainer? · Issue #451 · jlalmes/trpc-openapi
trpc-openapi ですが約 1 年ほどメンテナンスされておらず Issue でも別の人がメンテしたり fork したりの検討がされている状態です。ただ現状は完全な移行先となるほど活発な fork は見当たりませんでした。このような状態なので tRPC のバージョンアップにも追従できないなど弊害がありますので、自身でメンテナンスする覚悟がない限りは trpc-openapi の利用は避けた方がいいと思います。
次に trpc-openapi を併用しない場合の個人的に思う tRPC を採用する際の注意点について説明します。
tRPC 自体は素晴らしいライブラリで私自身も状況によっては有力な選択肢となっています。ただこの記事でも少し触れている通り、作成される API の path が独自の形式になっているので TypeScript 以外からもアクセスする API が必要になった時に trpc-openapi がないと別途作るか独自の path 形式を許容する事になります。開発関係者だと独自の path は許容できるかも知れませんが外部に公開する API だと恐らく厳しいと思いますので、この点についてはどう対策するのかを一度検討の上で tRPC の採用をご検討下さい。
ちなみに筆者は hono の RPC モードに載せ替えるという選択をしたのですが、もし現在の tRPC 及び周辺ライブラリについて有力な選択肢がありましたら、コメントにて教えて頂けると助かります。
概要
tRPC - Move Fast and Break Nothing. End-to-end typesafe APIs made easy. | tRPC
tRPC | tRPC
tRPCは公式ドキュメントの最初の一文にも書かれている通り、OpenAPI や gRPC のようなスキーマやコード生成をする必要なく、型に守られた API を簡単に構築および利用することができる RPC ライブラリです。
サーバーサイドもクライアントサイドも TypeScript で書かなければいけないという制約はありますが、このライブラリを使ってサーバーの API 定義および処理を書くだけでクライアントサイドの API にアクセスするコードに対してエディタのコード補完や型チェックによる警告の恩恵などを受ける事ができるようになります。
ちなみにこの TypeScript のみという制約も trpc-openapi
というプラグインパッケージを導入することにより、OpenAPI generator を利用した多言語のクライアントコードを生成することもできるようになります。こちらも便利で感動したので後ほど紹介したいと思います。
API 設計: gRPC、OpenAPI、REST の概要と、それらを使用するタイミングを理解する | Google Cloud 公式ブログ
蛇足ですがRPC 形式の API にあまり馴染みのない方はこちらの記事などがおすすめです
自分が要点を思い出す為のメモの流用なので説明が簡素な部分があるのはご了承ください。
サーバーサイド
サーバーフレームワークとの連携コードを除いた実装コード例。詳細はあとで説明するので雰囲気がつかめる程度に ...
等で省略しています。
import { initTRPC } from '@trpc/server'
import { z } from 'zod'
const t = initTRPC.create();
export const router = t.router;
export const middleware = t.middleware;
export const publicProcedure = t.procedure;
export const appRouter = router({
getUserById: publicProcedure
.input(
z.string()
).query(({ input }) => {
return {...}
}),
createUser: publicProcedure
.input(
z.object({
name: z.string().min(3),
bio: z.string().max(142).optional(),
}),
)
.mutation(({ input }) => {
// ... modify ...
return {...}
}),
})
export type AppRouter = typeof appRouter;
ルータ
tRPC
インスタンスの router
メソッドを使って作成します。
import { initTRPC } from '@trpc/server'
import { z } from 'zod'
const t = initTRPC.create()
export const appRouter = t.router({
getPosts: t.procedure
.input( z.string() )
.query(({ input }) => {
return { ... }
}),
})
export type AppRouter = typeof appRouter
-
initTRPC.create
メソッドを使ってtRPC
インスタンスを生成する-
tRPC
のインスタンスはアプリケーション上で 1 つのみにすること
-
-
router
に渡したオブジェクトのkey
が URL 上のpath
となって現れる- 上記の例だと
/getPosts
といった path になる
- 上記の例だと
-
router
に渡したオブジェクトのvalue
にはプロシージャ
もしくはルータ
を指定できる- 上記の例だと
t.procedure
を使ってプロシージャを指定している - ルータはネストさせることができる
- 上記の例だと
- 作成したルータの型定義を
typeof
で取得しクライアント側で利用できるようにexport
する- 上記のコードの
export type AppRouter = typeof appRouter
のこと
- 上記のコードの
ルータをネストさせる場合の例
export const postRouter = t.router({
getPosts: ...,
})
export const userRouter = t.router({
getUsers: ...,
})
export const appRouter = t.router({
users: userRouter,
posts: postRouter,
})
上記のようにネストさせると path
は /users.getPosts
や /posts.getUsers
といった .
区切りのパスで API が作成されます。このあたりが REST API との違いなので注意してください。
プロシージャ
tRPC
インスタンスの procedure
オブジェクトの各種メソッドを呼び出すことで生成します。
t.procedure.input(z.string()).query(({ input }) => {
return {...}
})
t.procedure
.input(
z.object({ id: z.string() }),
)
.mutation(({ input }) => {
const id = Date.now().toString()
const user: User = { id, ...input }
users[user.id] = user
return user
})
- データ取得系のプロシージャは
query
メソッドを使って定義する - データ更新系のプロシージャは
mutation
メソッドを使って定義する -
query
やmutation
メソッドを呼ぶ前に入出力のバリデーションの設定などが行える
入出力バリデーション
// 入力バリデーション
t.procedure
.input(
z.object({
id: z.string(),
message: z.string(),
})
)
.query(({input}) => {...})
// 別途バリデーション定義
const Post = z.object({
id: z.string(),
message: z.string(),
})
// 出力バリデーション
t.procedure.output(Post).query(({input}) => {...})
-
procedure
のinput
またはoutput
メソッドをバリデーションを設定する-
query
などが受け取る入力はバリデーション済みの値となる - 出力バリデーションがエラーとなった場合は
500: INTERNAL SERVER ERROR
となる
-
- バリデーションライブリには
zod
の他にもYup
やSuperstruct
が利用できる
サーバーフレームワークとの連携
各種アダプターが用意されているので作成したルータをサーバーフレームワークに組み込む。
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import { initTRPC } from '@trpc/server'
import fastify from 'fastify';
import { z } from 'zod'
const t = initTRPC.create()
const appRouter = t.router({...})
const server = fastify({
maxParamLength: 5000,
})
server.register(fastifyTRPCPlugin, {
prefix: '/trpc',
trpcOptions: { router: appRouter },
})
(async () => {
try {
await server.listen({ port: 3000 })
} catch (err) {
server.log.error(err)
process.exit(1)
}
})()
上記は Fastify を使う際の例で /trpc
のパスを起点に tRPC のパスが生成されます。例えば /trpc/getUsers
や /trpc/post.getPosts
などといった感じです。Fastify 以外にもアダプターは多種存在し、 tRPC は公式で用意しているものはこちら Official tRPC adapters | tRPC に記載されています。
クライアントサイド
クライアントからサーバーの RPC API にアクセスするコード例。
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'
import type { AppRouter } from '../path/to/server/trpc'
const client = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3000/trpc',
}),
],
})
const users = await client.getUsers.query()
// => [{ id: 'id_1', name: 'hoge' }, { id: 'id_2', name: 'fuga'}]
const post = await client.posts.createPost.mutate({ user_id: 'id_2', message: 'piyo' })
// => { id: 'post_id_1', user_id: 'id_2', message: 'piyo' }
- サーバー側で
export
したルータの型定義をimport
する- 上記のコードの
AppRouter
- 上記のコードの
- RPC クライアントを生成する
createTRPCProxyClient
のジェネリクスの部分にルータの型定義を指定する -
createTRPCProxyClient
の引数にサーバー側のエンドポイントを指定する - 生成した
client
を利用してアクセスするコードを書く- この際にサーバーの定義に合わせたコード補完が効きます
- 上記の例だと
client.
まで打つとclient.getUsers
とclient.posts
が候補に出る
- 上記の例だと
- サーバー側で
query
で定義したものはクライアント側もquery
を使う - サーバー側で
mutation
で定義したものはクライアント側はmutate
を使う -
query
,mutate
の引数もサーバー側のinput
に指定した型が反映されます - 戻り値の型もサーバー側から
return
された値の型が反映されます-
output
で出力のバリデーションを行った場合はこちらの型が反映されます
-
- この際にサーバーの定義に合わせたコード補完が効きます
公式からは TanStack Query や Next.js に対応したラッパークライアントを用意されています
trpc-openapi
jlalmes/trpc-openapi: OpenAPI support for tRPC 🧩
tRPC だけでも便利で開発体験は良くなるのですが、tRPC を使ったサーバーをユーザに公開したい場合や Web 以外のアプリを別言語で作る際には path
が独特の形式になるのでやや扱いづらくなってしまいます。具体的には RPC の考えで命名すると関数名に近い形になり REST API に比べて予想しづらい&覚えづらくなりますし、気をつけたとしてもネストさせた時に .
つなぎになるので REST API 前提のライブラリの便利機能が使えなかったりと、致命的ではないのですが辛い感じになります。
こういった問題を解決しつつ tRPC と OpenAPI の両方の恩恵を与えてくれるのが trpc-openapi
というライブラリで非常におすすめなので紹介させて頂きます。
メタ情報を扱える tRPC インスタンスの生成
import { initTRPC } from '@trpc/server'
import { OpenApiMeta } from 'trpc-openapi'
const t = initTRPC.meta<OpenApiMeta>().create()
.create()
を呼ぶ前に .meta<OpenApiMeta>()
を追加すれば OK
プロシージャに OpenAPI 定義の追加
trpc-openapi
を導入した状態のコード例
import { initTRPC } from '@trpc/server'
import { OpenApiMeta } from 'trpc-openapi'
const t = initTRPC.meta<OpenApiMeta>().create()
export const postRouter = t.router({
getUser: t.procedure
// この下の一行を追加するのみ
.meta({ openapi: { method: 'GET', path: '/users/{id}'} })
.input(
z.object({
id: z.string()
})
).query(({ input }) => {
return {...}
}),
})
export const appRouter = t.router({
users: userRouter,
})
router
および procedure
の定義の仕方は変わらず、.meta
のメソッドを使って OpenAPI 向けの定義を少し追加するだけです。既に作成したコードがあるのであれば既存のコードを変更することなく追加するだけで利用できます。
この場合 tRPC は users.getUser
という path
で API を作成するのですが、trpc-openapi
によって同じ処理を行ってくれる /users/{id}
の API も生成できる状態になっています。
サーバーフレームワークへの登録
Fastify
にはまだ対応してなかったので express
の例。
import { createExpressMiddleware } from '@trpc/server/adapters/express'
import express from 'express'
import { createOpenApiExpressMiddleware } from 'trpc-openapi'
const app = express()
app.use('/api/trpc', createExpressMiddleware({ router: appRouter }))
// 以下のコードを OpenAPI 用に追加する
app.use('/api', createOpenApiExpressMiddleware({ router: appRouter }))
app.listen(3000)
tRPC のハンドラーの登録とは別に OpenAPI 用のハンドラー登録のコードを追加します。これにより /api/trpc
には /api/trpc/users.getUser
といった tRPC 形式の API が、/api
には GET /api/users/{id}
といった OpenAPI 向けに設定した API が作成されます。
OpenAPI ドキュメントの利用
import { generateOpenApiDocument } from 'trpc-openapi'
export const openApiDocument = generateOpenApiDocument(appRouter, {
title: 'tRPC OpenAPI',
version: '1.0.0',
baseUrl: 'http://localhost:3000',
})
用意されたジェネレータを通すことにより OpenAPI ドキュメントを生成する事ができ、このオブジェクトを OpenAPI に対応した他のパッケージに渡せば利用できます。
import swaggerUi from 'swagger-ui-express'
app.use('/swagger', swaggerUi.serve)
app.use('/swagger', swaggerUi.setup(openApiDocument))
上記は SwaggerUI を利用する場合の例です。
このオブジェクトは JSON
か YAML
に変換してファイルに出力すれば、そのまま有効な OpenAPI の定義ファイルとなります。
const opneApiJson = JSON.stringfy(openApiDocument, null, 2)
fs.writeFileSync('openapi.json', openApiJson)
例えば上記のようなコードでファイルに出力し、OpenAPI generator に渡せば別言語のクライアントコードを生成することが出来ます。
まとめ
- tRPC を利用することによりサーバーの実装コードにそった型安全なクライアントコードを書くことができる
- trpc-openapi を利用することにより SwaggerUI を使った動作確認や多言語クライアントの対応ができる
Discussion