⌨️

tRPC の基本的な使い方と OpenAPI 連携

2023/02/09に公開

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 メソッドを使って定義する
  • querymutation メソッドを呼ぶ前に入出力のバリデーションの設定などが行える

入出力バリデーション

// 入力バリデーション
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}) => {...})
  • procedureinput または output メソッドをバリデーションを設定する
    • query などが受け取る入力はバリデーション済みの値となる
    • 出力バリデーションがエラーとなった場合は 500: INTERNAL SERVER ERROR となる
  • バリデーションライブリには zod の他にも YupSuperstruct が利用できる

サーバーフレームワークとの連携

各種アダプターが用意されているので作成したルータをサーバーフレームワークに組み込む。

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.getUsersclient.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 を利用する場合の例です。
このオブジェクトは JSONYAML に変換してファイルに出力すれば、そのまま有効な OpenAPI の定義ファイルとなります。

const opneApiJson = JSON.stringfy(openApiDocument, null, 2)
fs.writeFileSync('openapi.json', openApiJson)

例えば上記のようなコードでファイルに出力し、OpenAPI generator に渡せば別言語のクライアントコードを生成することが出来ます。

まとめ

  • tRPC を利用することによりサーバーの実装コードにそった型安全なクライアントコードを書くことができる
  • trpc-openapi を利用することにより SwaggerUI を使った動作確認や多言語クライアントの対応ができる

Discussion