• トップ
  • ブログ一覧
  • tRPCを触ってみる
  • tRPCを触ってみる

    おか(エンジニア)おか(エンジニア)
    2023.04.10

    IT技術

    気になっていたtRPCを実際に触ってみた記録です!

    tRPCとは

    公式から引用させてください。

    tRPC allows you to easily build & consume fully typesafe APIs without schemas or code generation.
    引用: https://trpc.io/docs/#introduction

    この通りで、スキーマ等なしで、簡単に型安全なAPIを作成することができます!

    ちなみに、tRPCは、TypeScriptを想定して作られているので、TypeScript以外での使用はできません><
    逆に、TypeScriptを使ったプロジェクトでは、かなり有用なツールになり得ます。

    サンプルアプリを構築してみる

    今回はNext.jsを使って、tRPCのサンプルアプリを作ってみようと思います。

    Next.jsのAPI Routesを利用して、tRPCの制限をかけたAPIを作成し、クライアント側でそのAPIを呼び出す、という形を想定しています。

    また、ディレクトリ構成は、公式がおすすめしていた以下の構成をとるようにします。

    1// 引用: https://trpc.io/docs/nextjs/setup#recommended-file-structure
    2.
    3├── prisma  # <-- if prisma is added
    4│   └── [..]
    5├── src
    6│   ├── pages
    7│   │   ├── _app.tsx  # <-- add `withTRPC()`-HOC here
    8│   │   ├── api
    9│   │   │   └── trpc
    10│   │   │       └── [trpc].ts  # <-- tRPC HTTP handler
    11│   │   └── [..]
    12│   ├── server
    13│   │   ├── routers
    14│   │   │   ├── _app.ts  # <-- main app router
    15│   │   │   ├── post.ts  # <-- sub routers
    16│   │   │   └── [..]
    17│   │   ├── context.ts   # <-- create app context
    18│   │   └── trpc.ts      # <-- procedure helpers
    19│   └── utils
    20│       └── trpc.ts  # <-- your typesafe tRPC hooks
    21└── [..]

    Next.jsをインストール

    1yarn create next-app --typescript

    tRPCのモジュールをインストール

    1npm install @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query zod

    サーバー側のtRPCのセットアップ

    APIを提供するサーバー側のセットアップを行なっていきます。

    tRPCのインスタンス生成

    tRPCを使用する上で必要なものを用意します。

    1// /src/server/trpc.ts
    2import { initTRPC, TRPCError } from '@trpc/server';
    3
    4// initTRPCはアプリ内で一度のみ使用する
    5const t = initTRPC.create({
    6  errorFormatter({ shape }) {
    7    return shape;
    8  },
    9});
    10
    11export const router = t.router;
    12
    13// API毎のプロシージャのベースとなるプロシージャ。認証別など、複数のベースプロシージャを定義できる
    14export const publicProcedure = t.procedure;
    15
    16// tRPC上のミドルウェア
    17export const middleware = t.middleware;
    18
    19// 全てのプロシージャをフラットに1つの名前空間にする場合に使用する
    20export const mergeRouters = t.mergeRouters;

    ルーターを作成する

    疎通確認のためのAPI、healthcheckのルートを追加してます。

    1// /src/server/routers/_app.ts
    2import { publicProcedure, router } from "../trpc";
    3
    4export const appRouter = router({
    5    healthcheck: publicProcedure.query(() => 'fine!')
    6});
    7
    8export type AppRouter = typeof appRouter;

    API/APIハンドラーを追加

    今回は、Next.js API Routes対象の/api/trpc/[trpc]にエンドポイントを置きます。

    1// src/pages/api/trpc/[trpc].ts
    2import * as trpcNext from '@trpc/server/adapters/next';
    3import { appRouter } from '@/server/routers/_app';
    4
    5export default trpcNext.createNextApiHandler({
    6  router: appRouter,
    7  onError({ error }) {
    8    if (error.code === 'INTERNAL_SERVER_ERROR') {
    9      console.error('Something went wrong', error);
    10    }
    11  },
    12  batching: {
    13    enabled: true,
    14  },
    15});

    APIにアクセスしてみる

    この時点で、http://localhost:3000/api/trpc/healthcheck にアクセスすると、以下のレスポンスが返ってきました!

    1{
    2    "result": {
    3        "data": "fine!"
    4    }
    5}

    Contextを追加してみる

    tRPCにはContextという機能があり、全てのtRPCプロシージャがアクセス可能なデータを格納できます。

    認証情報の共有などに便利ですが、今回はCookieを共有させてみます。

    1. Contextの型定義&生成処理を作成する
    1// /src/server/routers/context.ts
    2import * as trpc from '@trpc/server';
    3import * as trpcNext from '@trpc/server/adapters/next';
    4
    5interface CreateContextOptions {
    6  cookie: Partial<{
    7    [key: string]: string;
    8  }>,
    9}
    10
    11// context作成時の内部処理を切り出すことで、テスト時に便利になるそう
    12export async function createContextInner(opts: CreateContextOptions) {
    13  return {
    14    cookie: opts.cookie
    15  };
    16}
    17
    18export type Context = trpc.inferAsyncReturnType<typeof createContextInner>;
    19
    20export async function createContext(
    21  opts: trpcNext.CreateNextContextOptions,
    22): Promise<Context> {
    23  return await createContextInner({
    24    cookie: opts.req.cookies
    25  });
    26}
    2. initTRPCに連携する
    1// /src/server/trpc.ts
    2// ...
    3import { Context } from './context';
    4
    5const t = initTRPC.context<Context>().create({
    6  errorFormatter({ shape }) {
    7    return shape;
    8  },
    9});
    10
    11// ...
    3. 試しに取得してみる

    ここではmiddleware内で取得していますが、プロシージャ内でも取得できます。

    1// /src/server/trpc.ts
    2// ...
    3
    4const isAuthed = t.middleware(({ next, ctx }) => {
    5  // contextからcookieを取り出す
    6  if (!ctx.cookie['test-cookie']) {
    7    throw new TRPCError({
    8      code: 'UNAUTHORIZED',
    9    });
    10  }
    11  return next({
    12    ctx: {
    13      cookie: ctx.cookie
    14    },
    15  });
    16});
    17
    18export const protectedProcedure = t.procedure.use(isAuthed) // このベースプロシージャを適用すると、isAuthedがtrueの場合のアクセスのみ許可できる
    19
    20// ...

    クライアント側のtRPCのセットアップ

    _app.tsxの変更

    tRPCの高階コンポーネントをAppに適用します。

    1import '@/styles/globals.css'
    2import { trpc } from '@/utils/trpc'
    3import type { AppProps } from 'next/app'
    4
    5function App({ Component, pageProps }: AppProps) {
    6  return <Component {...pageProps} />
    7}
    8
    9export default trpc.withTRPC(App)

    tRPCのhookを用意する

    1// /src/utils/trpc.ts
    2import type { AppRouter } from '@/server/routers/_app';
    3import { httpBatchLink } from '@trpc/client';
    4import { createTRPCNext } from '@trpc/next';
    5
    6function getBaseUrl() {
    7  if (typeof window !== 'undefined')
    8    // ブラウザでは相対パスを利用
    9    return '';
    10
    11  return `http://localhost:${process.env.PORT ?? 3000}`;
    12}
    13
    14export const trpc = createTRPCNext<AppRouter>({
    15  config() {
    16    return {
    17      links: [
    18        httpBatchLink({
    19          url: `${getBaseUrl()}/api/trpc`,
    20
    21          // ヘッダー操作などはここでできる
    22          //   async headers() {
    23          //     return {
    24          //       authorization: 'token',
    25          //     };
    26          //   },
    27        }),
    28      ],
    29    };
    30  },
    31  ssr: typeof window === 'undefined',
    32});

    APIリクエストしてみる

    先ほど確認用のルート(healthcheck)を追加したので、クライアントから呼び出してみます。

    1// /src/pages/index.tsx
    2import { trpc } from '@/utils/trpc'
    3
    4export default function Home() {
    5  const { data, isLoading} = trpc.healthcheck.useQuery()
    6  return (
    7    <>
    8      {isLoading && 'loading...'}
    9      {data}
    10    </>
    11  )
    12}

    http://localhost:3000/ にアクセスしてみると、無事に取得したデータが表示されていました!

    セットアップ完了

    これでクライアントと、tRPCベースのAPIの疎通もできました!

    意外と簡単に構築でき、びっくりです。

    APIのinputの型付け

    tRPCといえば、APIの型付けができる点が魅力ポイントなので、やってみます!

    サーバー側のルート追加

    サブルーターを作る

    今回追加するルートに当たるサブルーターを用意します。

    今回はお試しなので、数字を受け取って、その数字の分だけ1を返すAPIを作ります。
    inputとしては、1~50までのnumbercount を受け取ります。

    1// /src/server/routers/sample.ts
    2import { router, publicProcedure } from '../trpc';
    3import { z } from 'zod';
    4
    5export const sampleRouter = router({
    6  repeat: publicProcedure
    7    // ここでinputの型指定
    8    .input(
    9      z.object({
    10        count: z.number().min(1).max(50),
    11      }),
    12    )
    13    .query(async ({ input }) => {
    14      const items = []
    15      for (let i = 0; i < input.count; i++) {
    16        items.push('1')
    17      }
    18
    19      return { items }
    20    }),
    21});

    今回はバリデーションライブラリとして、Zodを使用していますが、YupSuperstructも使用できます。

    サブルーターを全体ルーターに組み込み

    作成したサブルーターを全体ルーターに組み込みます。

    1// /src/server/routers/_app.ts
    2import { protectedProcedure, publicProcedure, router } from "../trpc";
    3import { sampleRouter } from "./sample";
    4
    5export const appRouter = router({
    6    healthcheck: publicProcedure.query(() => 'fine!'),
    7    sample: sampleRouter  // 追加
    8});
    9
    10// Export also type router type signature,
    11export type AppRouter = typeof appRouter;

    クライアント側で呼び出す

    APIリクエストする

    クライアント側から呼び出します。

    1// /src/pages/index.tsx
    2import { trpc } from '@/utils/trpc'
    3
    4export default function Home() {
    5  const { data: healthCheckData} = trpc.healthcheck.useQuery()
    6  const { data: repeatData } = trpc.sample.repeat.useQuery({ count: 20 }) // 追加
    7
    8  return (
    9    <>
    10      {healthCheckData}
    11      {repeatData?.items.map((item) => item)}
    12    </>
    13  )
    14}

    通信を確認してみる

    今回、最初に作ったヘルスチェックのクエリと、新しく作ったリピートクエリを2つ呼び出していましたが、エンドポイントも1つになって呼び出されています!

    ちなみに、複数のAPIがまとめてコールされているのは、httpBatchLinkやbatchingの設定をバッチ処理するように指定しているからです。
    もちろん、個別に実行したい場合は、設定を変更すればOKです。

    指定したinputの型以外を送信してみる

    1~50の範囲外の値をinputに入れてみました。

    結果は、エラー(400 Bad Request)となり、適合しないinputでは通信が失敗するようになっています!

    1// response
    2[
    3  {
    4    error: {
    5      message:
    6        '[\n {\n "code": "too_big",\n "maximum": 50,\n "type": "number",\n "inclusive": true,\n "exact": false,\n "message": "Number must be less than or equal to 50",\n "path": [\n "count"\n ]\n }\n]',
    7      code: -32600,
    8      data: {
    9        code: 'BAD_REQUEST',
    10        httpStatus: 400,
    11        stack: 'TRPCError: ...',
    12        path: 'sample.repeat',
    13      },
    14    },
    15  },
    16]

    outputの型付け

    レスポンスに当たるoutputの型付けも以下のように簡単にできます。

    1// /src/server/routers/sample.ts
    2import { router, publicProcedure } from '../trpc';
    3import { z } from 'zod';
    4
    5export const sampleRouter = router({
    6  repeat: publicProcedure
    7    .input(
    8      z.object({
    9        count: z.number().min(1).max(50),
    10      }),
    11    )
    12    // アウトプットのバリデーション追加
    13    .output(
    14      z.object({
    15        items: z.string().array(),
    16      }),
    17    )
    18    .query(async ({ input }) => {
    19      const items = []
    20      for (let i = 0; i < input.count; i++) {
    21        items.push('1')
    22      }
    23
    24      return { items }
    25    }),
    26}),

    ちなみに、アウトプットのバリデーションに引っかかった場合は、デフォルトでは以下のようなレスポンスが返ってきました。

    1// response
    2[
    3  {
    4    error: {
    5      message: 'Output validation failed',
    6      code: -32603,
    7      data: {
    8        code: 'INTERNAL_SERVER_ERROR',
    9        httpStatus: 500,
    10        stack: 'TRPCError: Output validation failed ...',
    11        path: 'sample.repeat',
    12      },
    13    },
    14  },
    15]

     

    まとめ

    今回は、tRPCの基本的な部分を触ってみました!

    今回はNext.jsでしたが、他の技術でも利用可能です。公式でボイラープレートやアダプターなど公開されているので、ぜひチェックしてみてください。
    https://trpc.io/docs/awesome-trpc

    ライトコードでは、エンジニアを積極採用中!

    ライトコードでは、エンジニアを積極採用しています!社長と一杯しながらお話しする機会もご用意しております。そのほかカジュアル面談等もございますので、くわしくは採用情報をご確認ください。

    採用情報へ

    おか(エンジニア)

    おか(エンジニア)

    おすすめ記事

    エンジニア大募集中!

    ライトコードでは、エンジニアを積極採用中です。

    特に、WEBエンジニアとモバイルエンジニアは是非ご応募お待ちしております!

    また、フリーランスエンジニア様も大募集中です。

    background