👌

tRPCだけに着目して一から構築していく

2023/10/18に公開

0.はじめに

tRPC(Typed RPC)は、型安全な方法でクライアントとサーバー間でリモートプロシージャコール(RPC)を行うためのライブラリです。tRPC を使用すると、クライアントとサーバーの間でデータの取得や操作に関する型情報を共有し、型安全性を確保できます。

実際の使用において、tRPC は通常、バックエンドフレームワーク(例:Express、Fastify、Next.js)と統合して使用されます。また、tRPC は他のライブラリやツールと併用され、例えば useQuery や zod などと連携します。バックエンドフレームワークとの統合や他のライブラリとの連携は、具体的な実装において重要な役割を果たします。

そのため、tRPC の解説は、他のライブラリと組み合わせて説明されることが多く、いったい tRPC の機能はどこからどこまでなのか、なかなか掴みきれませんでした。

そこで、純粋な tRPC の概念を理解するために、tRPC 自体に焦点を当てて、一から構築してみました。

この記事では、できるだけシンプルに tRPC を使って、サーバーとのやりとりを行なっていきます。

1.開発環境の準備

typescript のインストール

まずは、typescript をインストールして、初期化します。

npm install -D typescript ts-node
./node_modules/.bin/tsc --init

nodemon の準備

サーバー側のコードを修正した際に、ホットリロードできるようにnodemonの準備を行います。

npm install -D nodemon

下記、nodemon.jsonのファイルを追加します。

nodemon.json
{
  "watch": ["server"],
  "ext": "ts",
  "exec": "ts-node ./server/index.ts"
}

データベースの設定

取り扱うデータは下記のコードのようになっています。

  • findMany:User データを取得する。
  • findById:User の id を検索して User データを取得する。
  • create:新しく User データを追加する。
server/db.ts
type User = { id: string; name: string; townName: string };
// Imaginary database
const users: User[] = [];
export const db = {
  user: {
    findMany: async () => users,
    findById: async (id: string) => users.find((user) => user.id === id),
    create: async (data: { name: string; townName: string }) => {
      const user = { id: String(users.length + 1), ...data };
      users.push(user);
      return user;
    },
  },
};

tRPC のインストール

最後に、tRPC をインストールしていきましょう

npm install @trpc/server @trpc/client

これで環境設定は完了です。

2.基本的な使い方

tRPC は、サーバーサイドの情報を基にしてクライアントサイドにフィードバックする仕組みです。そのため、サーバーサイドの構築が重要になってきます。

まずは、サーバー側の設定を行なっていきます。tRPC を初期化します。

サーバーの設定

server/trap.ts
import { initTRPC } from "@trpc/server";

const t = initTRPC.create();
export const router = t.router;
export const publicProcedure = t.procedure;

次にrouterの設定を行なっていきます。API のエンドポイントを作る感じですね。

※zod 使えば、非常に簡単に記載できますが、ここではあえて使いません。(zod の便利さが沁みます・・・)

server/routers/user.ts
import { db } from "../db";
import { publicProcedure, router } from "../trpc";
// import { z } from "zod";

export const userRouter = router({
  userList: publicProcedure.query(async () => {
    const users = await db.user.findMany();
    return users;
  }),

  userById: publicProcedure
    .input(
      // z.string()
      (value): string => {
        if (typeof value === "string") {
          return value;
        }
        throw new Error("Input is not a string");
      }
    )
    .query(async (opts) => {
      const { input } = opts;
      const user = await db.user.findById(input);
      return user;
    }),

  userCreate: publicProcedure
    .input(
      // z.object({ name: z.string(), townName: z.string()})

      (value): { name: string; townName: string } => {
        if (
          typeof value === "object" &&
          value !== null &&
          "name" in value &&
          "townName" in value &&
          typeof value.name === "string" &&
          typeof value.townName === "string"
        ) {
          return value as { name: string; townName: string };
        }
        throw new Error("Input is not a valid value");
      }
    )

    .mutation(async (opts) => {
      const { input } = opts;
      const user = await db.user.create(input);
      return user;
    }),
});

input の入力値は、unknown 型になっていて、ここでバリデーションを行なっています。

操作については、

  • Query: データを取得するために使用され、通常データを変更しません。Getmethod に該当します。
  • Mutation: データを送信するために使用され、主に作成、更新、削除などの目的で利用されます。Postmethod に該当します。
  • Subscription: WebSockets を使う特殊なケースのみ使います。
server/routers/index.ts
import { userRouter } from "./user";
import { router } from "../trpc";

export const appRouter = router({
  user: userRouter,
});

export type AppRouter = typeof appRouter;

サーバーの立ち上げ関係を少し追加します。

server/index.ts
import { createHTTPServer } from "@trpc/server/adapters/standalone";
import { appRouter } from "./routers";

const server = createHTTPServer({
  router: appRouter,
});

server.listen(8000);

ここまでできたら、一度サーバーを立ち上げてみましょう!

npx nodemon

これでサーバーが立ち上がったかと思います。

クライアントからの呼び出し

クライアント側を作っていきます。

client/client.ts
import { createTRPCProxyClient, httpBatchLink } from "@trpc/client";
import type { AppRouter } from "../server/routers";

export const trpc = createTRPCProxyClient<AppRouter>({
  links: [
    httpBatchLink({
      url: "http://localhost:8000",
    }),
  ],
});
client/getUserlist.ts
import { trpc } from "./client";

async function main() {
  const users = await trpc.user.userList.query();
  console.log("Users:", users);
}

main().catch(console.error);

usersの行に着目して欲しいんですが、trpc.以降は推論してくれるのが非常に便利!

Vscode

次にクライアント側からサーバー側に向けてリクエストを投げてみたいと思います。

npx ts-node client/getUserlist.ts

すると、まだデータがないので[]が返ってくると思います。

同じように、createUserserchUserを作っていきましょう。

client/createUser.ts
import { trpc } from "./client";

async function main() {
  const createdUser = await trpc.user.userCreate.mutate({
    name: "taro",
    townName: "tokyo",
  });
  console.log("Created user:", createdUser);
}

main().catch(console.error);
client/serchUser.ts
import { trpc } from "./client";

async function main() {
  const user = await trpc.user.userById.query("1");
  console.log("User 1:", user);
}

main().catch(console.error);

こんな感じで型ガードが実行されます。非常に便利ですよね!

Vscode
Vscode

実際に、サーバーにリクエストを投げてみましょう

npx ts-node client/createUser.ts
npx ts-node client/serchUser.ts

3.ミドルウェアを追加

次に、header に authorization:Good が付与されていたら処理が走るといった簡易的な認証機能を追加してみます。

サーバー側

まずは、contextを作っていきます。

server/context.ts
import { inferAsyncReturnType } from "@trpc/server";
import type { CreateHTTPContextOptions } from "@trpc/server/adapters/standalone";

export async function createContext(opts: CreateHTTPContextOptions) {
  const isAuth = opts.req.headers.authorization === "Good";

  return { isAuth };
}

export type Context = inferAsyncReturnType<typeof createContext>;

次に、trpcを修正します。

server/trpc.ts
+ import { Context } from "./context";

- const t = initTRPC.create();
+ const t = initTRPC.context<Context>().create();

+ export const middleware = t.middleware;
server/index.ts
+ import { createContext } from "./context";

const server = createHTTPServer({
  router: appRouter,
+  createContext
});

次にmiddlewareを追加します。

server/middleware.ts
import { TRPCError } from "@trpc/server";
import { middleware, publicProcedure } from "./trpc";

const isAuth = middleware(async (opts) => {
  const { ctx } = opts;
  if (!ctx.isAuth) {
    throw new TRPCError({ code: "UNAUTHORIZED" });
  }
  return opts.next({
    ctx: {
      isAuth: ctx.isAuth,
    },
  });
});

export const authorizedProcedure = publicProcedure.use(isAuth);

次に、認証用のルーターを作っていきましょう。

server/routers/auth.ts
import { router } from "../trpc";
import { authorizedProcedure } from "../middleware";

export const authRouter = router({
  hello: authorizedProcedure
    .input(
      // z.object({
      //   name: z.string().describe("The name to say hello too."),
      //   townName: z.string(),
      // })
      (value): { name: string; townName: string } => {
        if (
          typeof value === "object" &&
          value !== null &&
          "name" in value &&
          "townName" in value &&
          typeof value.name === "string" &&
          typeof value.townName === "string"
        ) {
          return value as { name: string; townName: string };
        }
        throw new Error("Input is not a valid value");
      }
    )
    .output(
      // z.object({
      //   greeting: z.string(),
      // })
      (value): { greeting: string } => {
        if (
          typeof value === "object" &&
          value !== null &&
          "greeting" in value &&
          typeof value.greeting === "string"
        ) {
          return value as { greeting: string };
        }
        throw new Error("Output is not a valid value");
      }
    )
    .query((opts) => {
      const { input } = opts;
      return {
        greeting: `Welcome ${input.name}`,
      };
    }),
});

ここで、重要なポイントとしては、output にも型ガードが設置されていることですね。


最後に、ルーターを統合しましょう。下記のように書くとアクセスする際に、userauthの二つのエンドポイントができます。

server/routers/index.ts
export const appRouter = router({
  user: userRouter,
+  auth: authRouter,
});

今回は、これらを統合して、userのみのエンドポイントとします。

server/routers/index.ts
export const appRouter = router({
-  user: userRouter,
+  user: merge(userRouter, authRouter),
});

merge を使う場合、trpc に追加して、merge をインポートしてください。

server/trpc.ts
+ export const merge = t.mergeRouters;

クライアント側

ヘッダーに auth token を付与するclientを作っていきます。

client/authClient.ts
import { createTRPCProxyClient, httpBatchLink } from "@trpc/client";
import type { AppRouter } from "../server/routers";

let token: string;
export function setToken(newToken: string) {
  token = newToken;
}

export const trpc = createTRPCProxyClient<AppRouter>({
  links: [
    httpBatchLink({
      url: "http://localhost:8000",
      headers() {
        return {
          Authorization: token,
        };
      },
    }),
  ],
});

あとは、認証 OK の呼び出しを作って読んでみましょう

client/goodHello.ts
import { setToken, trpc } from "./authClient";

async function main() {
  setToken("Good");
  const hello = await trpc.user.hello.query({
    name: "taro",
    townName: "tokyo",
  });
  console.log("hello:", hello);
}

main().catch(console.error);
npx ts-node client/goodHello.ts

hello: { greeting: 'Welcome taro' }と返ってきたと思います。

client/badHello.ts
import { setToken, trpc } from "./authClient";

async function main() {
  setToken("bad");
  const hello = await trpc.user.hello.query({
    name: "jiro",
    townName: "osaka",
  });
  console.log("hello:", hello);
}

main().catch(console.error);
npx ts-node client/badHello.ts

TRPCClientError: UNAUTHORIZEDちゃんとエラーが返ってきますね!

4.エンドポイントについて

tRPC は OpenAPI のような標準的な API 仕様とは異なります。

OpenAPI(Open Application Programming Interface)は、API の設計と文書化を標準化するための仕様です。OpenAPI は、RESTful なウェブサービスやウェブアプリケーションの API を設計し、文書化するために広く使用されています。

fetch で動作チェック

どういうことか、実際に fetch でアクセスしてみましょう!

まずは、共通関数を定義します。

client/fetchAction.ts
export async function fetchAction({
  url,
  method,
  header,
  responseMessage,
  queryData,
}: {
  url: string;
  method: string;
  header?: { [key: string]: string };
  responseMessage?: string;
  queryData?: object;
}) {
  const requestOptions: RequestInit = {
    method: method,
    headers: {
      ...{
        "Content-Type": "application/json",
      },
      ...header,
    },
  };

  if (queryData) {
    requestOptions.body = JSON.stringify(queryData);
  }

  try {
    const response = await fetch(url, requestOptions);

    if (response.ok) {
      const user = await response.json();
      console.log(responseMessage, user.result.data);
    } else {
      console.error("Failed to fetch user data.");
    }
  } catch (error) {
    console.error;
  }
}

getUserlistcreateUser、`を関数にしていきます。

client/fetch/getUserlist.ts
import { fetchAction } from "../fetchAction";

const url = "http://localhost:8000/user.userList";
const method = "Get";
const responseMessage = "Users:";

fetchAction({ url, method, responseMessage })
client/fetch/createUser.ts
import { fetchAction } from "../fetchAction";

const url = "http://localhost:8000/user.userCreate";
const method = "Post";
const responseMessage = "Created user:";
const queryData = {
  name: "taro",
  townName: "tokyo",
};

fetchAction({ url, method, responseMessage, queryData })
client/fetch/serchUser.ts
import { fetchAction } from "../fetchAction";

const url = 'http://localhost:8000/user.userById?input="1"';
const method = "Get";
const responseMessage = "User 1:";

fetchAction({ url, method, responseMessage })

さっきまでと同じ動作をすると思います。

npx ts-node client/fetch/getUserlist.ts
npx ts-node client/fetch/createUser.ts
npx ts-node client/fetch/serchUser.ts
  • url:user.userListこんなエンドポイントは変ですよね。
  • Get か Post しかないため、serchUserは Get に分類されているため、Post は使えません。

OpenAPI 形式へ

trpc-openapi を使うと、OpenAPI 形式で実行できるようになります。

まずは、trpc-openapi をインストールします。

npm install trpc-openapi

次に tRPC インスタンスを書き換えます。

server/trpc.ts
+ import { OpenApiMeta } from 'trpc-openapi';

- const t = initTRPC.context<Context>().create();
+ const t = initTRPC.context<Context>().meta<OpenApiMeta>().create();

その後、プロシージャーを書き換えます。

なので、プロシージャーをzod仕様に変更します。

server/routers/auth.ts
export const authRouter = router({
  hello: authorizedProcedure
+    .meta({ openapi: { method: "GET", path: "/auth" } })
    .input(
+      z.object({
+        name: z.string().describe("The name to say hello too."),
+        townName: z.string(),
+      })
-       (value): { name: string; townName: string } => {
-         if (
-           typeof value === "object" &&
-           value !== null &&
-           "name" in value &&
-           "townName" in value &&
-           typeof value.name === "string" &&
-           typeof value.townName === "string"
-         ) {
-           return value as { name: string; townName: string };
-         }
-         throw new Error("Input is not a valid value");
-       }
    )
    .output(
+      z.object({
+        greeting: z.string(),
+      })
-       (value): { greeting: string } => {
-         if (
-           typeof value === "object" &&
-           value !== null &&
-           "greeting" in value &&
-           typeof value.greeting === "string"
-         ) {
-           return value as { greeting: string };
-         }
-         throw new Error("Output is not a valid value");
-       }
    )
    .query((opts) => {
      const { input } = opts;
      return {
        greeting: `Welcome ${input.name}`,
      };
    }),
});
server/index.ts
- import { createHTTPServer } from "@trpc/server/adapters/standalone";
+ import http from "http";
+ import { createOpenApiHttpHandler } from "trpc-openapi";

- const server = createHTTPServer({
-   router: appRouter,
-   createContext,
- });

+ const server = http.createServer(
+   createOpenApiHttpHandler({ router: appRouter, createContext })
+ );

呼び出す関数を作っていきます。

client/fetch/goodHello.ts
import { fetchAction } from "../fetchAction";

const url = "http://localhost:8000/auth?name=taro&townName=tokyo";
const method = "Get";
const responseMessage = "hello:";
const header = { Authorization: "Good" };

fetchAction({ url, method, responseMessage, header })

戻り値の形が先ほどと変わるので、少し修正して

client/fetchAction.ts
  if (response.ok) {
    const user = await response.json();
-    console.log(responseMessage, user.result.data);
+    console.log(responseMessage, user.greeting);
  }

実行します。

npx ts-node client/fetch/goodHello.ts

RestAPI と同じような扱いができましたね!

もし記事があなたのお役に立ったなら、ぜひ「いいね!」ボタンをクリックしてくださいね。

Discussion