RPCなのにOpenAPIも自動生成?oRPCの衝撃
はじめに
最近、API設計とフロントエンド実装をもっと楽にできないかと模索していた中で、
oRPC
というライブラリに出会いました。
このoRPC、RPCの手軽さとOpenAPIドキュメント生成の恩恵を
いいとこ取りしたかのような素晴らしい体験を提供してくれます。
しかも、Next.js(Server Actions含む)にも対応しており、
最新のフルスタック開発にぴったりな設計になっています。
本記事では、
- 「そもそもRPCとは何か?」
- 「RPCが持つ課題とは?」
- 「oRPCがそれをどう解決するのか?」
- 「Next.jsでの具体的な使い方」
について、実際に触った感想を交えながら紹介していきます!
RPCとはなにか
RPC(Remote Procedure Call)とは、リモートのサーバーにある関数を、まるでローカル関数のように呼び出せる仕組みのことです。
通常、フロントエンドからバックエンドに処理を依頼するには、REST API や GraphQL を通じてリクエストを送信し、レスポンスを受け取る必要があります。しかし、RPCでは関数を呼ぶだけというシンプルな書き心地で通信処理を行えるのが大きな特徴です。
以下はRPCで記載したAPI リクエストの例です。
const result = await api.add(1, 2)
console.log(result) // => 3
このようにAPIでは、フロントエンド側は関数を呼び出すだけで、エンドポイントや通信のプロトコルを意識することなくAPIにリクエストができます。
さらに、HonoのようなTypeScriptで利用するRPCは型安全性が高いため、パラメーターやレスポンスなどに厳格な型情報があるため、保守性・開発体験(DX)ともに高いといった特徴があります。
RPCが持つ課題
RPCの概要だけ見ると、型安全で開発体験(DX)も非常に良いという、メリットしかないように見えます。
しかし、実は大きな課題も存在します。
それが、OpenAPIなどのAPI仕様書が存在しないことです。
一般的なスキーマ駆動開発(Schema-First Development)では、まずOpenAPIなどを用いてAPIの仕様を明示的に定義します。
これにより、フロントエンド・バックエンド間で仕様を共有でき、開発のズレを防ぐことができます。
一方、RPCの場合、関数ベースで通信が行われるため、明示的なAPI仕様書が自然とは生まれません。
そのため、もし仕様書が必要であれば、開発者が別途手作業で用意する必要があるのです。
この仕様書不足は、
- チーム開発
- 外部クライアント向けAPI提供
- 将来的な保守・運用
といった場面で、大きな障害になる可能性があります。
特にクライアントワークをする際はAPI定義書を求められることは往々にしてあるので、これは非常に大きな課題です。
RPC側の対応策
では、RPCライブラリ(tRPCやHonoなど)は、こうした課題に全く対応していないのでしょうか?
それぞれ3rd partyのライブラリやミドルウェアを導入することでOpenAPIと連携し、API仕様書を生成することができます。
- tRPCの場合:
trpc-openapi
というライブラリを導入することでOpenAPIと連携することができるようです。
- Honoの場合:
Honoでは、Honoに組み込みの3rd partyのミドルウェアを使用することで実現できます。
また、本記事で触れるoRPCの場合は、ファーストクラス、つまりOpenAPIを標準サポートしているので、最小限の労力でOpenAPI準拠のAPIを簡単に公開でき、仕様書も簡単に作成できます。
Honoも簡単にできてしまうのですが、両者を触り比較した印象だと、oRPCのほうが手間が少なくDXの面では優位性を感じました。
Hono vs oRPC
OpenAPIとの連携において、HonoとoRPCの比較をしてみましょう。
Honoの場合
まずはHonoからです。
以下の記事を参考に見ていきましょう。
(細かい説明などは記事をご参照ください)
プロジェクト作成
まずはHonoプロジェクトを作成します。
yarn create hono .
必要なパッケージ追加
次にパッケージも入れていきます。
yarn add @hono/zod-openapi @hono/swagger-ui
スキーマ定義
zodのスキーマを定義します。
src/models/task.ts
import { z } from '@hono/zod-openapi';
export const TaskSchema = z
.object({
uuid: z.string().openapi({
example: '12345678-e29b-41d4-a716-123456789000',
}),
title: z.string().openapi({
example: 'Title',
}),
description: z.string().openapi({
example: 'Description',
}),
completed: z.boolean().openapi({
example: false,
}),
priority: z.number().min(1).max(5).openapi({
example: 3,
}), // タスクの優先度(1〜5)
})
.openapi('TaskSchema');
// 新しいタスク作成用のリクエストモデル
export const CreateTaskSchema = z
.object({
title: z.string().openapi({
example: 'Title',
}),
description: z.string().openapi({
example: 'Description',
}),
priority: z.number().min(1).max(5).default(3).openapi({
example: 3,
}),
completed: z.boolean().default(false).openapi({
example: false,
}),
})
.openapi('CreateTaskSchema');
// タスクのレスポンスモデル
export const TaskListSchema = z.array(TaskSchema).openapi('TaskListSchema');
エラーレスポンスの方も定義します。
src/models/error.ts
import { z } from '@hono/zod-openapi';
export const ErrorResponse = z
.object({
message: z.string(),
stackTrace: z.string().optional(),
})
.openapi('ErrorResponse');
GET /api/tasks エンドポイントの作成
次にエンドポイントを作成します。
一般的な200で成功時のレスポンス、500で先程定義した、ErrorResponseを返します。
src/api/tasks/getTasks.ts
import { createRoute } from '@hono/zod-openapi';
import { ErrorResponse, TaskListSchema } from '../../models';
export const getTasksRoute = createRoute({
path: '/',
method: 'get',
description: '登録されているすべてのタスクのリストを取得します',
responses: {
200: {
description: 'OK',
content: {
'application/json': {
schema: TaskListSchema,
},
},
},
500: {
description: 'Internal Server Error',
content: {
'application/json': {
schema: ErrorResponse,
},
},
},
},
});
GET Handlerの作成
次に実際にタスク一覧を返却するビジネスロジックを作成します。
src/handlers/tasks/getTasksHandler.ts
type TaskSchema = z.infer<typeof TaskSchema>;
export const getTasksHandler: RouteHandler<typeof tasksRoute, {}> = async (c) => {
try {
const tasks: TaskSchema[] = [
{
uuid: '12345678-e29b-41d4-a716-123456789000',
title: 'Buy Groceries',
description: 'Purchase milk, eggs, and bread from the store',
completed: false,
priority: 2,
},
{
uuid: '23456789-c23e-59c3-c234-234567890111',
title: 'Morning Run',
description: 'Run 5 kilometers in the park',
completed: false,
priority: 5,
},
];
return c.json(tasks, 200);
} catch (e) {
console.error(e);
return c.json({ message: 'Internal Server Error', stackTrace: e }, 500);
}
};
POST /api/tasks エンドポイント
POSTリクエストについても同様に行っていきます。
src/api/tasks/createTasks.ts
import { createRoute } from '@hono/zod-openapi';
import { CreateTaskSchema, ErrorResponse, TaskSchema } from '../../models';
type CreateTaskSchema = z.infer<typeof CreateTaskSchema>;
export const createTasksRoute = createRoute({
path: '/',
method: 'post',
description: '新たにタスクを登録します',
request: {
body: {
content: {
'application/json': {
schema: CreateTaskSchema,
},
},
},
},
responses: {
200: {
description: 'OK',
content: {
'application/json': {
schema: TaskSchema,
},
},
},
500: {
description: 'Internal Server Error',
content: {
'application/json': {
schema: ErrorResponse,
},
},
},
},
});
POST Handler作成
こちらもGETリクエスト同様にHandlerを作成します
src/handlers/tasks/createTasksHandler.ts
type TaskSchema = z.infer<typeof TaskSchema>;
export const createTasksHandler: RouteHandler<
typeof createTasksRoute,
{}
> = async (c) => {
try {
const newTask = await c.req.json<CreateTaskSchema>();
const uuid = crypto.randomUUID();
const task: TaskSchema = {
uuid,
...newTask,
};
return c.json(task, 200);
} catch (e) {
console.error(e);
return c.json({ message: 'Internal Server Error', stackTrace: e }, 500);
}
};
Router定義
最後にAPIのRouterを定義します。
OpenAPIHono()
というオブジェクトに対して、openapi()
という関数をチェーンで定義します。
見ての通り、Honoの場合はエンドポイントが増えるほど、このチェーンが長くなり可読性・保守・DXの面で懸念がでてきそうです。
とはいえ、Honoの場合は、Swagger ドキュメントに自動反映されるので、この利便性はoRPCにはない魅力の一つと言えそうです。
import { OpenAPIHono } from '@hono/zod-openapi';
import { createTasksHandler, createTasksRoute } from './createTask';
import { getTasksHandler, getTasksRoute } from './getTasks';
export const tasksApi = new OpenAPIHono();
tasksApi
.openapi(getTasksRoute, getTasksHandler)
.openapi(createTasksRoute, createTasksHandler);
import { swaggerUI } from '@hono/swagger-ui';
import { OpenAPIHono } from '@hono/zod-openapi';
import { tasks, tasksRoute } from './tasks';
export const api = new OpenAPIHono();
api
.route('/tasks', tasksApi)
.doc('/specification', {
openapi: '3.0.0',
info: {
title: 'API',
version: '1.0.0',
},
})
.get(
'/doc',
swaggerUI({
url: '/api/specification',
})
);
次に、oRPCの方を見ていきましょう。
oRPCの場合
ここではプロジェクトはNext.jsなど既存プロジェクトがあるという前提で進めます。
(必要なパッケージの追加から行うので、プロジェクトがない方はcreate-next-app
などをして、新規に作成していただく必要があります)
こちらは、以下の公式ドキュメントに沿って見ていきましょう。
必要なパッケージ追加
まずはパッケージの追加を行います。
yarn add @orpc/server@latest @orpc/client@latest @orpc/openapi@latest @orpc/zod@latest zod
Routeの定義
ドキュメントにあるものをコピーしてきます。
import type { IncomingHttpHeaders } from 'node:http'
import { ORPCError, os } from '@orpc/server'
import { z } from 'zod'
const PlanetSchema = z.object({
id: z.number().int().min(1),
name: z.string(),
description: z.string().optional(),
})
export const listPlanet = os
.route({ method: 'GET', path: '/planets' })
.input(z.object({
limit: z.number().int().min(1).max(100).optional(),
cursor: z.number().int().min(0).default(0),
}))
.output(z.array(PlanetSchema))
.handler(async ({ input }) => {
// your list code here
return [{ id: 1, name: 'name' }]
})
export const findPlanet = os
.route({ method: 'GET', path: '/planets/{id}' })
.input(z.object({ id: z.coerce.number().int().min(1) }))
.output(PlanetSchema)
.handler(async ({ input }) => {
// your find code here
return { id: 1, name: 'name' }
})
export const createPlanet = os
.$context<{ headers: IncomingHttpHeaders }>()
.use(({ context, next }) => {
const user = parseJWT(context.headers.authorization?.split(' ')[1])
if (user) {
return next({ context: { user } })
}
throw new ORPCError('UNAUTHORIZED')
})
.route({ method: 'POST', path: '/planets' })
.input(PlanetSchema.omit({ id: true }))
.output(PlanetSchema)
.handler(async ({ input, context }) => {
// your create code here
return { id: 1, name: 'name' }
})
export const router = {
planet: {
list: listPlanet,
find: findPlanet,
create: createPlanet
}
}
ここで重要なのは以下の2つです
-
.route
: HTTP メソッドとパスを定義します。 -
.output
: OpenAPI 仕様の自動生成を有効にします。
特にoutputはOpenAPIとの連携に直結するので、しっかりと定義しましょう。
OpenAPI仕様の生成
最後にOpenAPI仕様を生成します。
specにgenerate
に渡されたrouter
を元に生成したOpen API仕様が格納されます。
import { OpenAPIGenerator } from '@orpc/openapi'
import { ZodToJsonSchemaConverter } from '@orpc/zod'
import { router } from './shared/planet'
const generator = new OpenAPIGenerator({
schemaConverters: [
new ZodToJsonSchemaConverter()
]
})
const spec = await generator.generate(router, {
info: {
title: 'Planet API',
version: '1.0.0'
}
})
console.log(JSON.stringify(spec, null, 2))
あとは、これをscriptとして実行するか、/api/openapi/docs
のようなエンドポイントを用意して呼び出すだけで仕様書を簡単に作成・公開できます。
ここでの最大のメリットは、
- Honoのようにエンドポイントが増えても、チェーン構造が複雑化しない
- 可読性・保守性・開発体験(DX)が劣化しない
という点にあります。
なぜそれが可能なのか?
それは、以下のようなrouter定義にすべての情報を集約しているからです。
export const router = {
planet: {
list: listPlanet,
find: findPlanet,
create: createPlanet
}
}
つまり、router中心で設計が完結するため、
常に「OpenAPIファースト」な開発体験を維持できる。
これこそが、oRPCの真の強みだと筆者は考えています。
では、より具体的に見ていきましょう。
今度は、実際に筆者がoRPCをNext.js(App Router)に統合した実装例を紹介します。
Next.jsとoRPCの統合
以下のドキュメントに従い、Next.jsとの統合を進めます
※ プロジェクトは既にある前提なので、まだ作成していない方はcreate-next-app
で作成しておいてください
必要なパッケージの追加
まずは、パッケージを入れていきます
yarn add @orpc/server@latest @orpc/client@latest @orpc/openapi@latest @orpc/zod@latest zod up-fetch
ちなみに、筆者の場合ここで、fetchを便利にしたライブラリであるup-fetch
を入れています。
RPCHandlerの作成
次に、ドキュメントに従いRPCHandlerを作成していきます
Catch-all segments
を使用してapp/rpc/[[...rest]]/route.ts
を作成します。
import { RPCHandler } from '@orpc/server/fetch'
const handler = new RPCHandler(router)
async function handleRequest(request: Request) {
const { response } = await handler.handle(request, {
prefix: '/rpc',
context: {}, // Provide initial context if needed
})
return response ?? new Response('Not found', { status: 404 })
}
export const GET = handleRequest
export const POST = handleRequest
export const PUT = handleRequest
export const PATCH = handleRequest
export const DELETE = handleRequest
routerはまだ未作成なので、作成後、importします。
up-fetchの定義
次にup-fetch
の定義をしてきます。
今回はダミーデータにdummyjsonを使用するので、baseUrlにdummyjsonのURLを設定します。
import { up } from 'up-fetch'
export const upfetch = up(fetch, () => ({
baseUrl: 'https://dummyjson.com',
}))
スキーマ定義
今回はダミーデータとしてTodoを使用していくので、dummyjsonに合わせて型を定義していきます。
import z from 'zod'
export const TodoSchema = z.object({
id: z.number({
message: 'ID is required',
}),
todo: z.string().min(1, { message: 'Title is required' }),
completed: z.boolean(),
userId: z.number({
message: 'User ID is required',
}),
})
export type Todo = z.infer<typeof TodoSchema>
エンドポイント・ロジック作成
APIのエンドポイントと処理を作成します。
output
にOpenAPI 仕様として生成されるスキーマを定義し、upfetchにはレスポンスの型をジェネリクスで渡しています。
ジェネリクスの型がないと通常のfetchのようにanyとなるため、型安全性が薄れてしまうので、ここも極力定義しておきましょう。
import { os } from '@orpc/server'
import { z } from 'zod'
import {
type Todo,
TodoSchema,
} from '~/features/todo/types/schemas/todo-schema'
import { upfetch } from '~/lib/up-fetch'
// ? https://dummyjson.com/docs/todos#todos-all
export const listTodo = os
.route({ method: 'GET' })
.input(
z.object({
limit: z.number().min(1).max(100).optional(),
skip: z.number().min(0).optional(),
}),
)
.output(
z.object({
todos: z.array(TodoSchema),
total: z.number(),
skip: z.number(),
limit: z.number(),
}),
)
.handler(async ({ input }) => {
type Response = {
todos: Todo[]
total: number
skip: number
limit: number
}
const response = await upfetch<Response>('/todos', {
params: input,
})
return response
})
// ? https://dummyjson.com/docs/todos#todos-single
export const findTodo = os
.route({ method: 'GET' })
.input(TodoSchema.pick({ id: true }))
.output(TodoSchema)
.handler(async ({ input }) => {
const response = await upfetch<Todo>(`/todos/${input.id}`)
return response
})
router定義
次にrouterの定義を行い、情報を集約します。
他にもuserのエンドポイントを増やしたい場合、users: { list: userList }
というようにできるので、この設計思想はかなり筋が良いように思えます。
import { findTodo, listTodo } from '~/features/todo/api/route'
export const router = {
todos: {
list: listTodo,
find: findTodo,
},
}
RPC clientの定義
最後に、フロントエンドで呼び出すclientを定義します。
import { createORPCClient } from '@orpc/client'
import { RPCLink } from '@orpc/client/fetch'
import type { RouterClient } from '@orpc/server'
import type { router } from '~/lib/orpc-router'
const link = new RPCLink({
url: 'http://localhost:3000/rpc',
// You can add headers as needed
// headers: { Authorization: 'Bearer token' },
})
export const client: RouterClient<typeof router> = createORPCClient(link)
fetcherの定義
ここでRPCの関数を呼び出し、実際にコンポーネントで呼びだすfetcherを定義します。
client
から関数としてAPIを呼び出せているのがわかるかと思います。
import { unstable_cacheTag as cacheTag } from 'next/cache'
import { client } from '~/lib/orpc-client'
export const getTodos = async (
params?: Parameters<typeof client.todos.list>[0],
) => {
'use cache'
cacheTag('getTodos')
const res = await client.todos.list({
limit: params?.limit ?? 100,
skip: params?.skip ?? 0,
})
return res
}
export const getTodo = async ({
id,
}: Parameters<typeof client.todos.find>[0]) => {
'use cache'
cacheTag(`getTodo/${id}`)
const res = await client.todos.find({ id })
return res
}
Todo一覧ページ
以下のようにTodo一覧ページでfetcherを呼び出すことでtodoの一覧を取得できます。
import Link from 'next/link'
import { Suspense } from 'react'
import { TodoForm } from '~/features/todo/components/todo-form'
import { getTodos } from '~/features/todo/server/fetcher'
export default function Home() {
return (
<div className="flex flex-col gap-4">
<h1 className="text-2xl font-bold">Todo List</h1>
<TodoForm />
<Suspense fallback={<div>Loading...</div>}>
{getTodos().then(({ todos }) => {
return todos.map((item) => (
<div key={item.id} className="flex items-center gap-2">
<Link href={`/todo/${item.id}`}>{item.todo}</Link>
<p className={item.completed ? 'text-green-500' : 'text-red-500'}>
{item.completed ? 'Completed' : 'Not completed'}
</p>
</div>
))
})}
</Suspense>
</div>
)
}
Todo詳細ページ
さらに、Todo詳細ページも同様に取得できます。
import { Suspense } from 'react'
import { getTodo } from '~/features/todo/server/fetcher'
export default async function TodoIdPage({
params,
}: {
params: Promise<{ todoId: string }>
}) {
const { todoId } = await params
return (
<div className="flex flex-col gap-4">
<Suspense fallback={<div>Loading...</div>}>
{getTodo({ id: Number(todoId) }).then((todo) => (
<div className="flex flex-col gap-2">
<h2 className="text-2xl font-bold">{todo.todo}</h2>
<p className={todo.completed ? 'text-green-500' : 'text-red-500'}>
{todo.completed ? 'Completed' : 'Not completed'}
</p>
</div>
))}
</Suspense>
</div>
)
}
Server Action
最後にoRPCのv1.0.0-beta.5でBraking Changeとして搭載された機能のServer Action
の実装をしていきます。
Server Actionもドキュメントに従い実装します。
まずはパッケージの導入です。
必要なパッケージの追加
yarn add @orpc/react@latest
続いてはServer側で実行される関数を定義します。
Server Actionの作成
ここではTodoを追加する処理を作成します。
(dummyjsonにエンドポイントはあるものの、実際にデータの追加は行われないようです)
記述はほとんどAPIエンドポイントの定義と同じです。
errors
には任意のエラーが定義でき、messageとerrorオブジェクトを定義できます。
そして、handlerに実際に行いたいロジックを記述します。
(今回であれば、Todoの追加です)
最後に.actionable()
でServer Actionとして扱うようにチェーンを繋げます。
'use server'
import { os } from '@orpc/server'
import { revalidateTag } from 'next/cache'
import z from 'zod'
import {
type Todo,
TodoSchema,
} from '~/features/todo/types/schemas/todo-schema'
import { upfetch } from '~/lib/up-fetch'
// ? https://dummyjson.com/docs/todos#todos-add
export const addTodoAction = os
.input(TodoSchema.omit({ id: true }))
.errors({
ADD_TODO_ERROR: {
message: 'Error adding todo',
data: z.object({ error: z.string() }),
},
})
.handler(async ({ input }) => {
const response = await upfetch<Todo>('/todos/add', {
method: 'POST',
body: input,
})
revalidateTag('getTodos')
return response
})
.actionable()
Client側の実装
最後にClient側の実装です。
以下のようにuseServerAction
というhookを使用して実装を行います。
'use client'
import { isDefinedError, onError, onSuccess } from '@orpc/client'
import { useServerAction } from '@orpc/react/hooks'
import { addTodoAction } from '~/features/todo/actions/add-todo-action'
export function TodoForm() {
const { execute, data, status } = useServerAction(addTodoAction, {
interceptors: [
onError((error) => {
if (isDefinedError(error)) {
alert(error.message)
}
}),
onSuccess((data) => {
alert(`Todo added successfully: ${data.todo} for user ${data.userId}`)
}),
],
})
const action = (form: FormData) => {
const todo = form.get('todo') as string
execute({ todo, completed: false, userId: 1 })
}
return (
<form className="flex gap-x-2 px-2" action={action}>
<input
type="text"
name="todo"
defaultValue={data?.todo}
className="bg-white text-black p-1 rounded-md shadow-md"
/>
<button
type="submit"
disabled={status === 'pending'}
className="bg-blue-500 text-white p-1 rounded-md shadow-md hover:bg-blue-500/90 cursor-pointer transition-all duration-300 px-4"
>
{status === 'pending' ? 'Adding...' : 'Add'}
</button>
</form>
)
}
見ての通り、interceptors
で成功時とエラー時の処理をコールバックとして定義できます。
これにより、簡単にToastが表示できたり、Modalが表示できたり、アニメーションを効かせるなどができます。
React19から追加となったuseActionState
だと、このような仕組みがないので自前でエラー時や成功時のハンドリングを行う関数を用意する必要があります。
これについては、筆者の別の記事で記載しているので、気になる方はチェックしてみてください。
そして、その他ですが、execute
はaddTodoAction
が入っており、actionとして実行されます。
dataにはactionの実行結果が渡され、初期表示時はundefined
ですが、addTodoAction実行後では、その実行結果が格納されます。
status
は"error" | "idle" | "success" | "pending"
のstatusを持っており、状態に応じてスタイルを変更するなどが可能です。
これ以外に、error
にはerrorオブジェクトが格納されて返却されるので、必要に応じて使用することができます。
ちなみに、oRPCが提供するerrorオブジェクトは型安全(type safe)に設計されています。
実装にあるisDefinedError
関数を使うことで、追加したカスタムエラーを型ガードを用いて絞り込み、適切にハンドリングすることが可能です。
実際に型情報を確認したところ、カスタムで追加したエラーも含め、エラーオブジェクトが厳密に型定義されていることを確認できました。
以下が、返却されたerrorの型情報です。
const error: Error | {
readonly defined: boolean;
readonly code: "ADD_TODO_ERROR";
readonly status: number;
readonly data: { ... 1 more };
toJSON: () => { ... 5 more };
name: string;
message: string;
stack?: string;
cause: unknown;
}
(parameter) error: Error | ORPCError<"ADD_TODO_ERROR", {
error: string;
}>
この仕組みは、他のRPCライブラリには見られない特徴であり、カスタムのResult型やError型を別途用意することなく、直感的に型安全なエラーハンドリングを実現できる点が非常に魅力的です。
OpenAPI仕様の生成
最後に、OpenAPI仕様の生成の機能を作成します。
以下のようにRoute Handlerとして定義していきます。
筆者の場合、root ディレクトリーにopenapi.json
というファイルも生成されるようにしています。
このAPIのレスポンスやJSONファイルの内容をSwagger editorに貼るだけで仕様書の作成は完了です。
import fs from 'node:fs'
import { OpenAPIGenerator } from '@orpc/openapi'
import { ZodToJsonSchemaConverter } from '@orpc/zod'
import { NextResponse } from 'next/server'
import { router } from '~/lib/orpc-router'
export async function GET() {
const generator = new OpenAPIGenerator({
schemaConverters: [new ZodToJsonSchemaConverter()],
})
const spec = await generator.generate(router, {
info: {
title: 'My Dummy Todo API',
version: '1.0.0',
},
})
fs.writeFileSync('openapi.json', JSON.stringify(spec, null, 2), 'utf8')
return NextResponse.json(spec, {
status: 200,
headers: {
'Content-Type': 'application/json',
},
})
}
openapi.json
{
"info": {
"title": "My Dummy Todo API",
"version": "1.0.0"
},
"openapi": "3.1.1",
"paths": {
"/todos/list": {
"get": {
"operationId": "todos.list",
"parameters": [
{
"name": "limit",
"in": "query",
"style": "deepObject",
"explode": true,
"schema": {
"type": "number",
"minimum": 1,
"maximum": 100
}
},
{
"name": "skip",
"in": "query",
"style": "deepObject",
"explode": true,
"schema": {
"type": "number",
"minimum": 0
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"todos": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "number"
},
"todo": {
"type": "string",
"minLength": 1
},
"completed": {
"type": "boolean"
},
"userId": {
"type": "number"
}
},
"required": [
"id",
"todo",
"completed",
"userId"
]
}
},
"total": {
"type": "number"
},
"skip": {
"type": "number"
},
"limit": {
"type": "number"
}
},
"required": [
"todos",
"total",
"skip",
"limit"
]
}
}
}
}
}
}
},
"/todos/find": {
"get": {
"operationId": "todos.find",
"parameters": [
{
"name": "id",
"in": "query",
"required": true,
"style": "deepObject",
"explode": true,
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": {
"type": "number"
},
"todo": {
"type": "string",
"minLength": 1
},
"completed": {
"type": "boolean"
},
"userId": {
"type": "number"
}
},
"required": [
"id",
"todo",
"completed",
"userId"
]
}
}
}
}
}
}
}
}
}
ちゃんと仕様書として成立していることが確認できました。
API仕様書 UIの自動作成
2025年4月22日にこの記事で記載したSwagger Pluginによる自動のAPI仕様書のUI自動作成について以下のv1.1.0リリースで追加されました。
(なんとこの記事を読んで、このPluginを追加してくれたようで、私に知らせてくれるという神対応をしていただきました)
前段はさておき、早速実装してみましょう。
既存packageの更新
まずは、既存のpackageをv1.1.0に更新します。
bun update @orpc/client @orpc/openapi @orpc/react @orpc/server @orpc/valibot
OpenAPI仕様の生成を行うAPIを作成
次に、OpenAPI仕様の生成の項目で作成した処理をspec.json
というエンドポイントとして切り出します。
これはAPI仕様のUIを表示するAPI エンドポイントがspec.json
というファイルを参照するためです。
(ここではよく理解できなくて問題ありません。次のAPI仕様のUIを作成する処理を見たら腑に落ちると思います。)
import { OpenAPIGenerator } from '@orpc/openapi'
import { experimental_ValibotToJsonSchemaConverter as ValibotToJsonSchemaConverter } from '@orpc/valibot'
import { NextResponse } from 'next/server'
import { router } from '~/lib/orpc-router'
export async function GET() {
const generator = new OpenAPIGenerator({
schemaConverters: [new ValibotToJsonSchemaConverter()],
})
const spec = await generator.generate(router, {
info: {
title: 'My Dummy Todo API',
version: '1.0.0',
},
// This is the prefix for the API
servers: [{ url: '/rpc' }],
})
return NextResponse.json(spec, {
status: 200,
headers: {
'Content-Type': 'application/json',
},
})
}
API仕様書 UIの作成(Swagger/Scalar Plugin)
それでは、元あったapp/api/openapi/doc
のエンドポイントを修正し、API仕様書のUI作成の処理を記述します。
以下のドキュメントを参考に実施します。
import { OpenAPIHandler } from '@orpc/openapi/fetch'
import { OpenAPIReferencePlugin } from '@orpc/openapi/plugins'
import { CORSPlugin } from '@orpc/server/plugins'
import { experimental_ValibotToJsonSchemaConverter as ValibotToJsonSchemaConverter } from '@orpc/valibot'
import { type NextRequest, NextResponse } from 'next/server'
import { router } from '~/lib/orpc-router'
export async function GET(req: NextRequest) {
//? https://orpc.unnoq.com/docs/openapi/openapi-handler
const openApiHandler = new OpenAPIHandler(router, {
plugins: [
new CORSPlugin(),
// ? https://orpc.unnoq.com/docs/openapi/plugins/openapi-reference
new OpenAPIReferencePlugin({
// ? https://github.com/unnoq/orpc/tree/main/packages/valibot
schemaConverters: [new ValibotToJsonSchemaConverter()],
specGenerateOptions: {
info: {
title: 'My Dummy Todo API',
version: '1.0.0',
},
// This is the prefix for the API
servers: [{ url: '/rpc' }],
},
}),
],
})
const { matched, response } = await openApiHandler.handle(req, {
prefix: '/api/openapi/doc',
})
if (matched) {
return response
}
return NextResponse.json(
{
message: 'No matching route found',
},
{
status: 404,
},
)
}
重要なのは以下のOpenAPIHandler内のOpenAPIReferencePlugin
です。
これがv1.1.0で新たに追加されたPluginです
const openApiHandler = new OpenAPIHandler(router, {
plugins: [
new CORSPlugin(),
// ? https://orpc.unnoq.com/docs/openapi/plugins/openapi-reference
new OpenAPIReferencePlugin({
// ? https://github.com/unnoq/orpc/tree/main/packages/valibot
schemaConverters: [new ValibotToJsonSchemaConverter()],
specGenerateOptions: {
info: {
title: 'My Dummy Todo API',
version: '1.0.0',
},
// This is the prefix for the API
servers: [{ url: '/rpc' }],
},
}),
],
})
schemaConerters
では使用しているSchemaのConverterを使用します。
私の場合、Valibotを使用しているので、ValibotのConverterを使用します。
次のspecGenerateOption
でOpen API生成のoptionを設定でき、infoでtitleやversionを指定したりすることができます。
servers
のurl
に/rpc
というprefixを渡しているのは、APIのエンドポイントのprefixが/rpc
だからです。
この記述がないとAPI仕様書 UIのエンドポイントがhttp://localhost:3000/todos/list
のようになり/rpc
というprefixがない不正なエンドポイントで出力されるので、記述しています。
import { createORPCClient } from '@orpc/client'
import { RPCLink } from '@orpc/client/fetch'
import type { RouterClient } from '@orpc/server'
import type { router } from '~/lib/orpc-router'
const link = new RPCLink({
url: 'http://localhost:3000/rpc',
// headers: { Authorization: 'Bearer token' },
})
export const client: RouterClient<typeof router> = createORPCClient(link)
また以下のように明示的にdocsPath
やspecPath
を指定することもできます。
型情報も合わせて記載しますが、このように明示的にAPI仕様書UIのエンドポイントやOpenAPI JSON(OpenAPI仕様の生成がされるAPIなど)を定義することができます。
デフォルト値についてはspecPath
が/spec.json
なので、今回は明示的に指定する必要はないです。
理由はOpenAPI JSONを生成するエンドポイントはapi/openapi/doc/spec.json
だからです。
これはAPI仕様のUIを表示するAPI エンドポイントが
spec.json
というファイルを参照するためです。
上記の記述をしたのはこのspecPathがあるためです。
またdocsPath
についても同様に今回は明示的な定義は不要です。
new OpenAPIReferencePlugin({
// ? https://github.com/unnoq/orpc/tree/main/packages/valibot
schemaConverters: [new ValibotToJsonSchemaConverter()],
specGenerateOptions: {
info: {
title: 'My Dummy Todo API',
version: '1.0.0',
},
// This is the prefix for the API
servers: [{ url: '/rpc' }],
},
specPath: '/spec.json',
docsPath: '/reference',
})
* The URL path at which to serve the OpenAPI JSON.
*
* @default '/spec.json'
*/
specPath?: HTTPPath;
/**
* The URL path at which to serve the API reference UI.
*
* @default '/'
*/
docsPath?: HTTPPath;
そして、最後にOpenAPIHandlerのhandle
にて、prefixを指定します。
このprefixにOpenAPI 仕様書のUIを生成する本APIのエンドポイントを指定します。
そして、このprefixと実際にリクエストがあったエンドポイントが一致すればmatch
がtrueになるので、OpenAPI 仕様書のUIを出力します(handleの返却値のresponseを返却します)
const { matched, response } = await openApiHandler.handle(req, {
prefix: '/api/openapi/doc',
})
if (matched) {
return response
}
これで実装は完了です。
非常に簡単にAPI仕様書の生成からUI自動作成までできました。
それでは、実際に生成されるUIを見てみましょう。
API 仕様書のUI確認
結論から言うと、しっかりと公式ドキュメントのAPIドキュメントが作成されました。
(Scalar仕様のドキュメントが生成されるので当然ですが・・・)
まず、各種ツールやライブラリ・言語に応じて仕様書の内容が各種ツールやライブラリ言語に対応したものになります。
もちろん、これをダウンロードするためのリンクもあり、JSONファイルとしてダウンロードすることができます。
また、詳細を確認するときっちりとリクエストに必要なパラメータ、レスポンスステータスに応じた型のドキュメント化もされています。
右側のJSONでレスポンスサンプルを確認できる部分はSchmaの確認もできるようです。
また、PostmanのようにAPIをテストするための画面もついています。
おわりに
いかがでしたでしょうか?
筆者はoRPCは結構な確率で流行しそうだなと思っています。
ここまでOpenAPIファーストなRPCが他にない点やServer ActionのサポートにおいてもoRPCは優位性を獲得していると言えると筆者は考えています。
ただ、Swagger UIとの自動連携がHonoではできるので、こういった機能は今後追加されてほしいところで、さらなるDXの改善が進むことを願っています。
→ SwaggerのようなAPI仕様書 UIの自動生成は2025年4月22日のv1.1.0にてPluginが追加され改修済みです
とはいえ、驚きなのが、これが、まだ安定版の正式リリースでない点です。
筆者としては、普通に安定版としての通用するレベルだと感じましたが、皆さんはどうでしょうか?
ぜひ、気になる方はoRPCを1度、触ってみてはいかがでしょうか?
ここまで読んでくださり、ありがとうございます。
参考になれば幸いです。
参考文献
Discussion