🅾️

RPCなのにOpenAPIも自動生成?oRPCの衝撃

に公開

はじめに

最近、API設計とフロントエンド実装をもっと楽にできないかと模索していた中で、
oRPCというライブラリに出会いました。

https://orpc.unnoq.com/

この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)ともに高いといった特徴があります。

https://hono.dev/

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と連携することができるようです。

https://github.com/trpc/trpc-openapi

  • Honoの場合:
    Honoでは、Honoに組み込みの3rd partyのミドルウェアを使用することで実現できます。

https://hono.dev/examples/zod-openapi

また、本記事で触れるoRPCの場合は、ファーストクラス、つまりOpenAPIを標準サポートしているので、最小限の労力でOpenAPI準拠のAPIを簡単に公開でき、仕様書も簡単に作成できます。

Honoも簡単にできてしまうのですが、両者を触り比較した印象だと、oRPCのほうが手間が少なくDXの面では優位性を感じました。

https://orpc.unnoq.com/

Hono vs oRPC

OpenAPIとの連携において、HonoとoRPCの比較をしてみましょう。

Honoの場合

まずはHonoからです。
以下の記事を参考に見ていきましょう。
(細かい説明などは記事をご参照ください)

https://zenn.dev/slowhand/articles/b7872e09b84e15

プロジェクト作成

まずは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にはない魅力の一つと言えそうです。

src/api/tasks/index.ts
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);
src/api/index.ts
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などをして、新規に作成していただく必要があります)

こちらは、以下の公式ドキュメントに沿って見ていきましょう。

https://orpc.unnoq.com/docs/openapi/getting-started

必要なパッケージ追加

まずはパッケージの追加を行います。

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で作成しておいてください

https://orpc.unnoq.com/docs/integrations/nextjs

必要なパッケージの追加

まずは、パッケージを入れていきます

yarn add @orpc/server@latest @orpc/client@latest @orpc/openapi@latest @orpc/zod@latest zod up-fetch

ちなみに、筆者の場合ここで、fetchを便利にしたライブラリであるup-fetchを入れています。

https://github.com/L-Blondy/up-fetch

RPCHandlerの作成

次に、ドキュメントに従いRPCHandlerを作成していきます
Catch-all segmentsを使用してapp/rpc/[[...rest]]/route.tsを作成します。

https://nextjs.org/docs/pages/building-your-application/routing/dynamic-routes#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を設定します。

https://dummyjson.com/

src/lib/up-fetch.ts
import { up } from 'up-fetch'

export const upfetch = up(fetch, () => ({
  baseUrl: 'https://dummyjson.com',
}))

スキーマ定義

今回はダミーデータとしてTodoを使用していくので、dummyjsonに合わせて型を定義していきます。

src/features/todo/types/schemas/todo-schema.ts
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となるため、型安全性が薄れてしまうので、ここも極力定義しておきましょう。

src/features/todo/api/route.ts
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 }というようにできるので、この設計思想はかなり筋が良いように思えます。

src/lib/orpc-router.ts
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を呼び出せているのがわかるかと思います。

src/features/todo/server/fetcher.ts
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の一覧を取得できます。

src/app/page.tsx
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詳細ページも同様に取得できます。

src/app/todo/[todoId]/page.tsx
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の実装をしていきます。

https://github.com/unnoq/orpc/releases/tag/v1.0.0-beta.5

Server Actionもドキュメントに従い実装します。

https://orpc.unnoq.com/docs/server-action

まずはパッケージの導入です。

必要なパッケージの追加

yarn add @orpc/react@latest

続いてはServer側で実行される関数を定義します。

Server Actionの作成

ここではTodoを追加する処理を作成します。
(dummyjsonにエンドポイントはあるものの、実際にデータの追加は行われないようです)

記述はほとんどAPIエンドポイントの定義と同じです。
errorsには任意のエラーが定義でき、messageとerrorオブジェクトを定義できます。

そして、handlerに実際に行いたいロジックを記述します。
(今回であれば、Todoの追加です)
最後に.actionable()でServer Actionとして扱うようにチェーンを繋げます。

src/features/todo/actions/add-todo-action.ts
'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を使用して実装を行います。

src/features/todo/compoennt/todo-form.tsx
'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だと、このような仕組みがないので自前でエラー時や成功時のハンドリングを行う関数を用意する必要があります。
これについては、筆者の別の記事で記載しているので、気になる方はチェックしてみてください。

https://zenn.dev/sc30gsw/articles/6b43b44e04e89e

そして、その他ですが、executeaddTodoActionが入っており、actionとして実行されます。

dataにはactionの実行結果が渡され、初期表示時はundefinedですが、addTodoAction実行後では、その実行結果が格納されます。

status"error" | "idle" | "success" | "pending"のstatusを持っており、状態に応じてスタイルを変更するなどが可能です。

これ以外に、errorにはerrorオブジェクトが格納されて返却されるので、必要に応じて使用することができます。

ちなみに、oRPCが提供するerrorオブジェクトは型安全(type safe)に設計されています。

実装にあるisDefinedError関数を使うことで、追加したカスタムエラーを型ガードを用いて絞り込み、適切にハンドリングすることが可能です。

https://orpc.unnoq.com/docs/client/error-handling

実際に型情報を確認したところ、カスタムで追加したエラーも含め、エラーオブジェクトが厳密に型定義されていることを確認できました。
以下が、返却された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に貼るだけで仕様書の作成は完了です。

src/app/api/openapi/doc/route.ts
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"
                  ]
                }
              }
            }
          }
        }
      }
    }
  }
}

ちゃんと仕様書として成立していることが確認できました。

swagger

API仕様書 UIの自動作成

2025年4月22日にこの記事で記載したSwagger Pluginによる自動のAPI仕様書のUI自動作成について以下のv1.1.0リリースで追加されました。
(なんとこの記事を読んで、このPluginを追加してくれたようで、私に知らせてくれるという神対応をしていただきました)

https://x.com/unnoqcom/status/1914690222383882737

https://github.com/unnoq/orpc/releases/tag/v1.1.0

前段はさておき、早速実装してみましょう。

既存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を作成する処理を見たら腑に落ちると思います。)

app/api/openapi/doc/spec.json/route.ts
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作成の処理を記述します。

以下のドキュメントを参考に実施します。

https://orpc.unnoq.com/docs/openapi/plugins/openapi-reference

app/api/openapi/doc/route.ts
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を指定したりすることができます。

serversurl/rpcというprefixを渡しているのは、APIのエンドポイントのprefixが/rpcだからです。
この記述がないとAPI仕様書 UIのエンドポイントがhttp://localhost:3000/todos/listのようになり/rpcというprefixがない不正なエンドポイントで出力されるので、記述しています。

src/lib/orpc-client.ts
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)

また以下のように明示的にdocsPathspecPathを指定することもできます。
型情報も合わせて記載しますが、このように明示的に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ファイルとしてダウンロードすることができます。

API docs1

また、詳細を確認するときっちりとリクエストに必要なパラメータ、レスポンスステータスに応じた型のドキュメント化もされています。

API docs2

右側のJSONでレスポンスサンプルを確認できる部分はSchmaの確認もできるようです。
API docs3

また、PostmanのようにAPIをテストするための画面もついています。

API docs4

おわりに

いかがでしたでしょうか?
筆者はoRPCは結構な確率で流行しそうだなと思っています。

ここまでOpenAPIファーストなRPCが他にない点やServer ActionのサポートにおいてもoRPCは優位性を獲得していると言えると筆者は考えています。

ただ、Swagger UIとの自動連携がHonoではできるので、こういった機能は今後追加されてほしいところで、さらなるDXの改善が進むことを願っています。
→ SwaggerのようなAPI仕様書 UIの自動生成は2025年4月22日のv1.1.0にてPluginが追加され改修済みです

とはいえ、驚きなのが、これが、まだ安定版の正式リリースでない点です。
筆者としては、普通に安定版としての通用するレベルだと感じましたが、皆さんはどうでしょうか?

ぜひ、気になる方はoRPCを1度、触ってみてはいかがでしょうか?
ここまで読んでくださり、ありがとうございます。
参考になれば幸いです。

参考文献

https://orpc.unnoq.com/

https://orpc.unnoq.com/docs/openapi/getting-started

https://orpc.unnoq.com/docs/integrations/nextjs

https://github.com/unnoq/orpc/releases/tag/v1.0.0-beta.5

https://orpc.unnoq.com/docs/server-action

https://orpc.unnoq.com/docs/client/error-handling

https://hono.dev/

https://github.com/trpc/trpc-openapi

https://hono.dev/examples/zod-openapi

https://zenn.dev/slowhand/articles/b7872e09b84e15

https://github.com/L-Blondy/up-fetch

https://nextjs.org/docs/pages/building-your-application/routing/dynamic-routes#catch-all-segments

https://dummyjson.com/

https://zenn.dev/sc30gsw/articles/6b43b44e04e89e

https://x.com/unnoqcom/status/1914690222383882737

https://github.com/unnoq/orpc/releases/tag/v1.1.0

https://orpc.unnoq.com/docs/openapi/plugins/openapi-reference

Discussion