0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

tRPC のコンテキストとミドルウェアの解説

Posted at

前回の投稿で作成した React TODO アプリでは、コンテキストの内容を完全にすっ飛ばしていましたが、コンテキストは非常に強力な tRPC の機能の一つですので、本記事で解説したいと思います。
またコンテキストはミドルウェアと一緒に使用することで柔軟な API のコントロールが可能ですので、ミドルウェアに関しても取り上げたいと思います。

この記事に書かれた内容は現在の安定版である v9 に関する情報となっています。
tRPC は今後 v10 へのアップデートを予定しており、各種 API が変更される予定です。
そのため v10 以降では本記事の内容では動作しない可能性がある点、ご注意ください :bow:

コンテキストとは?

tRPC だけでなく昨今のメジャーな API フレームワークの多くは、リクエストの処理に必要なさまざまな情報を "コンテキスト" としてリクエスト毎に保持し、後続の処理にそのデータを渡すことが可能です。
リクエスト毎の情報というと、他には POST リクエストの HTTP ボディやクエリストリングなどがありますが、コンテキストにはそれらに含まれない (含めることができない) 情報を格納することに利用されます。
例えば、以下のような使い方があります。

  • HTTP Authorization ヘッダーのトークンを検証し、そのトークンから取得したユーザー情報などをコンテキストに格納し、後続の処理に渡す。
  • データーベースのコネクションをコンテキストに格納する。

tRPC ではコンテキストを使用するには、まず createContext という関数を作成します。
この関数がリクエストの度に呼び出され、コンテキストを作成します。
(以下は公式ページの簡略版です。)

import * as trpc from "@trpc/server";
import * as trpcExpress from "@trpc/server/adapters/express";

// Authrization ヘッダーをデコードして、抽出したユーザー情報をコンテキストとして返す。
// ヘッダーがない場合はユーザー情報を nulll としてコンテキストを返す。
export async function createContext(opts?: trpcExpress.CreateExpressContextOptions) {
  async function getUserFromHeader() {
    if (opts?.req.headers.authorization) {
      const user = await decodeJwtToken(req.headers.authorization.split(' ')[1])
      return user;
    }
    return null;
  }

  const user = await getUserFromHeader(opts?.req.headers.authorization || "");
  return { user };
}

// ちなみに Context タイプを定義しておくといろいろな場面で便利
export type Context = trpc.inferAsyncReturnType<typeof createContext>;

// さらに Context を使ってルーター作成用のヘルパー関数を作成しておくと、後々のルーター作成に便利
export function createRouter() {
  return trpc.router<Context>();
}

作成した createContext 関数を Express または Next.js のミドルウェア作成時にパラメーターとして渡します。

const app = express();

app.use(
  '/trpc',
  trpcExpress.createExpressMiddleware({
    router: appRouter,
    createContext,
  })
);

ミドルウェア

ところで前述の createContext の例では、コンテキストに null を格納せずに、以下のように HTTP 401 エラーを返すことも可能です。

export async function createContext(opts?: trpcExpress.CreateExpressContextOptions) {

  async function getUserFromHeader() {
    if (opts?.req.headers.authorization) {
      const user = await decodeJwtToken(req.headers.authorization.split(' ')[1])
      return user;
    }
-   return null;
+   // HTTP 401 エラーを返す
+   throw new trpc.TRPCError({code:"UNAUTHORIZED"})
  }
  const user = await getUserFromHeader();
  return { user };
}

が、API によってトークンによる認証の要不要を分けたい場合もあると思います。

export const AppRouter = createRouter()
  .merge("public.", publicRouter)        // だれでもアクセス可能
  .merge("protected.", protectedRouter)  // 認証が必要

このような場合はミドルウェアを使用すると特定のルーターに処理を追加でき、柔軟に API を実装することが可能です。
ミドルウェアに関しては Express などでも頻繁に利用されるので、ご存知の方も多いと思います。
(tRPC 公式ページ)

Express との違いで特筆すべきなのは、tRPC ではミドルウェアでコンテキストの情報を変更したうえで後続処理に渡すことができる (Context Swapping) という点です。

例えば (前述とほぼ同様ですが) createContext ではトークンの検証失敗時にコンテキストに null を格納するようにしておき…

type Context = { user: { username: string } };
type InitialContext = Context | null;

export const createContext = async ({
  req,
  res,
}: ExpressContext): Promise<InitialContext> => {
  try {
    const user = await validateAuthToken(req.headers.authorization || "");
    return { user };
  } catch (_) {
    return null;
  }
};

ミドルウェア側で認証エラーかどうかの処理を行う、ということも可能です。
公式ページでは以下のように、ミドルウェアを追加したルーター作成用のヘルパー関数を作っています。

export function createProtectedRouter() {
  return trpc.router<InitialContext>().middleware(({ ctx, next }) => {
    if (ctx) return next({ ctx });   // ctx は null ではないことが保証される
    throw new trpc.TRPCError({ code: "UNAUTHORIZED" });
  });
}

const publicRouter = trpc.router<InitialContext>()
  .query(/* ... */)

const protectedRouter = createProtectedRouter()
  .query(/* ... */)

export const AppRouter = createRouter()
  .merge("public.", publicRouter)        // だれでもアクセス可能
  .merge("protected.", protectedRouter)  // 認証が必要

ミドルウェアを切り出して実装し、plugable にすることも可能です。
この場合 internals 配下の MiddlewareFunction を使用します。

import type { MiddlewareFunction } from '@trpc/server/src/internals/middlewares';

// 認証用ミドルウェア
// MiddlewareFunction<入力コンテキスト, 出力コンテキスト, メタデータ>
export const auth: MiddlewareFunction<InitialContext, Context, {}> = ({
  ctx,
  next,
}) => {
  if (ctx) return next({ ctx });
  throw new TRPCError({ code: "UNAUTHORIZED" });
};

このミドルウェアを使った処理はこのように書けます。
使用時は公式の書き方とほぼ変わらないですが、テストがしやすくなっています。

const protectedRouter = createRouter()
  .middleware(auth)
  .query(/* ... */)

export const AppRouter = createRouter()
  .merge("public.", publicRouter)        // だれでもアクセス可能
  .merge("protected.", protectedRouter)  // 認証が必要

本記事で使用している tRPC v9 では、前述の通り MiddlewareFunction 型は internals 配下にあるので、この型を開発者が使用することはこのバージョンでは意図されていないかもしれません。
今後予定されている v10 では開発者者側で利用できるように export の方式が変更となるようです。

ミドルウェアはそれ以外にも

  • logging
  • raw input (query/mutation でバリデーションを行う前のユーザーインプット) を操作
    などで利用が可能であり、公式ページでも紹介されていますので、是非ご参照ください。

最後まで読んでいただきありがとうございました :bow:

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?