背景
- 今まで GraphQL を Next.js と併用する場合、スキーマ情報をフロントエンドと共有するために graphql-codegen を用いて型を自動生成し、 apollo client や urql 向けのプラグインを用いてブラウザから直接 GraphQL リクエストを送信するクライアントを作り、受け取ったデータをコンポーネントに反映していた。
- しかし RSC や Server Actions の登場により、ブラウザ上で直接 GraphQL リクエストを送信する必要がなくなり、Node ランタイム上で GraphQL リクエストのやりとりができるだけで十分になった。
- そこで、とある新規プロジェクトでシンプルに GraphQL リクエストの送受信だけできるクライアントライブラリである graphql-request を採用した。
- graphql-codegen が graphql-request 向けに生成した SDK が、 mutation 実行時にエラーが発生した際に例外を throw してしまい、これを Server Action 上で実行するとエラーメッセージを取得することができない問題がある。
- そこで mutation 用のクライアント実装をラップして、 mutation 成功時も失敗時も例外なしで返り値として受け取れるようなファクトリを実装したので、その一連の流れについて説明する。
環境
- Node 20.10.0
- pnpm 8.12.1
- typescript 5.3.3
- next 14.0.4
- graphql 16.8.1
- graphl-request 6.1.0
- @graphql-codegen/cli 5.0.0
- @graphql-codegen/typescript 4.0.1
- @graphql-codegen/typescript-operations 4.0.1
- @graphql-codegen/typescript-graphql-request 6.1.0
事前準備
Next.js のプロジェクトに必要なパッケージをインストールする。
$ pnpm add graphl-request graphql
$ pnpm add -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-operations @graphql-codegen/typescript-graphql-request
graphql-codegen 向けの設定ファイルをプロジェクトルートに設置する。
overwrite: true
schema: "http://api:3000/graphql" # GraphQL エンドポイント
documents: "app/graphql/**/*.graphql" # GraphQL クエリを書くファイルの置き場
generates:
app/_generated/schema.ts: # 自動生成される型情報の置き場
plugins:
- "typescript"
- "typescript-operations"
- "typescript-graphql-request"
package.json の scripts に自動生成用のコマンドを追加する。
{
"scripts": {
...,
"generate": "gql-gen --config codegen.yml"
}
}
GraphQL の mutation 用クエリを該当箇所に置く(※各自の環境で GraphQL 上に実装されている mutation を実行できるクエリを書いてください)。
mutation registerUser($attributes: UserInputAttributes!) {
registerUser(input: $attributes) {
user { id }
}
}
自動生成コマンドを実行する。
$ pnpm run generate
> test-app@0.1.0 generate /app
> gql-gen --config codegen.yml
✔ Parse Configuration
✔ Generate outputs
app/_generated/schema.ts
にファイルが自動生成されていれば成功。
次に Server Action 上で実行する SDK を準備する。
"use server";
import { GraphQLClient } from "graphql-request";
import { getSdk, RegisterUserMutationVariables } from "app/_generated/schema";
const ApiClient = getSdk(new GraphQLClient(process.env.API_ENDPOINT_URL));
export const registerUser = (variables: RegisterUserMutationVariables) => {
return ApiClient.registerUser(variables);
};
export default ApiClient;
この registerUser を Client Component 上で呼び出せば、 Server Action として GraphQL リクエストが実行される。ここまでで事前準備は完了となる。
問題点
この registerUser をそのまま実行してエラーが返ってくる場合、 ApiClient は例外を throw する。
その例外をキャッチすると graphql-request 内に実装された ClientError というエラークラスのインスタンスを取得できる。
import { registerUser } from "app/ApiClient";
import { RegisterUserMutationVariables } from "app/_generated/schema";
const onSubmit = async (variables: RegisterUserMutationVariables) => {
try {
const data = registerUser(variables); // 返り値は mutation 成功時のレスポンスの `data` オブジェクトが直に返ってくる
if (data.registerUser) {
// mutation 成功時の処理
}
} catch (error) {
if (error instanceof ClientError) {
// バックエンドからエラーが返ってきた場合の処理
}
throw error;
}
};
この時、このエラーオブジェクトに格納されたメッセージが、バックエンドから返ってきたエラーメッセージだけでなく、リクエスト/レスポンス本文の JSON も含んだ文字列になってしまっている。
graphql-request の内部実装を見ると下記のようにエラーメッセージを作っている。
export class ClientError extends Error {
response: GraphQLResponse
request: GraphQLRequestContext
constructor(response: GraphQLResponse, request: GraphQLRequestContext) {
const message = `${ClientError.extractMessage(response)}: ${JSON.stringify({
response,
request,
})}`
super(message)
// 中略
this.response = response
this.request = request
// 省略
}
private static extractMessage(response: GraphQLResponse): string {
return response.errors?.[0]?.message ?? `GraphQL Error (Code: ${response.status})`
}
}
そして、この例外は Node ランタイム上で throw されるため、これを Server Action の返り値としてブラウザ上で受け取った時、 error.request や error.response といったプロパティは失われてしまう。
つまり、これではバックエンドが返したエラーメッセージを直接取得することができず、手元には謎の JSON を含む文字列しか残らない。ユーザーにエラー情報をフィードバックすることが難しいので、何とかバックエンドから返ってきたレスポンスをそのまま受け取ることはできないだろうか。
SDK をラップする
graphql-codegen が生成したファイル内に実装された getSdk は、第二引数として withWrapper という関数を受け取ることができる。
ここに関数を渡した場合、ミドルウェア的に動作するため、ここで例外をキャッチして返り値に含めるという手も考えられたが、残念ながら返り値の型がミドルウェアを挟む前と後で等しくなるよう型定義で制限されていたため適わなかった。
そこで getSdk を実行した後の関数をラップして返り値を加工することにした。
目標としては、下記のように Server Action の返り値がバックエンドから返ってきた JSON がそのまま格納されるような状態だ。
// Before
const onSubmit = async (variables) => {
const data = await registerUser(variables);
// data には `{ registerUser: { user: { id: 1 } } }` のようなオブジェクトが入ってくる
}
// After
const onSubmit = async (variables) => {
const result = await registerUser(variables);
// result には `{ data: { registerUser: { user: { id: 1 } } }, errors: null }` のようなオブジェクトが入ってくる
}
これを型安全に書くには、下記のように ApiClient.ts を修正する。
"use server";
import { GraphQLClient, ClientError } from "graphql-request";
import { getSdk } from "app/_generated/schema";
type BaseMutationType<V, R> = (variables: V) => Promise<R>;
type MutationResult<T> = {
data: T | null;
errors: { message: string; extensions?: unknown }[];
};
const ApiClient = getSdk(new GraphQLClient(process.env.API_ENDPOINT_URL));
const mutationFactory = <V, R>(mutation: BaseMutationType<V, R>) => {
return async (variables: V): Promise<MutationResult<R>> => {
try {
const data = await mutation(variables);
return { data, errors: [] };
} catch (error) {
if (error instanceof ClientError) {
return { data: null, errors: error.response.errors ?? [] };
}
throw error;
}
};
};
export const registerUser = mutationFactory(ApiClient.registerUser);
このように実装するだけで、 mutation()
の引数に実行したい mutation 関数を渡すだけで、引数・返り値の型が自動で定義され、かつ返り値として data と errors が含まれるオブジェクトを得ることができ、 try - catch を使わなくてもエラー判定とエラーメッセージの取得をスムーズに処理することができる。
const onSubmit = async (variables) => {
const result = await registerUser(variables);
if (result.data?.registerUser) {
// mutation 成功時の処理
} else {
const messages = result.errors?.map((error) => error.message) ?? [];
// messages に格納されたエラーメッセージの内容をユーザーにフィードバックすることができる
}
};
まとめ
graphql-request と Server Actions の組み合わせでは例外処理で不便が発生したため、クライアントライブラリの実装をラップすることで、例外を投げることなく元々バックエンドが返してきた JSON をそのまま返すよう挙動を変更した。
便利になった一方で、今回のサンプル実装は Progressive Enhancement を満たしていないという課題があり、改善の余地がある。(参考: Server Action と useFormState)
Progressive Enhancement の観点では form 要素の action 属性に直接渡しても動作するのが望ましい一方で、フィールドごとのエラー表示をするためにフロントエンドとバックエンドでエラー時のフィールド名を合わせなければいけなくなるなど、今まで分離できていたバックエンドのインターフェースとフロントエンドのフォームが密結合になってしまう問題もありそうと予想している。
AppRouter や RSC/ServerActions は、まだリリースされたばかりで運用事例も少ないので、これから様々な事例に遭遇しながら改善策を見つけていきたい。