Rails: APIレスポンスに応じたエラーページ表示を自動化する(翻訳)
エラー処理は、ソフトウェアを構築するうえで重要な側面のひとつです。弊社では、エラーページの表示を可能な限り自動化して、対処方法を統一できるようにしています。この作業を自動化することで、弊社のプロダクトエンジニアは本来の業務である高品質なソフトウェアのリリースに専念できるようになりました。
本記事では、私たちのneetoアプリケーションで用いているエラーページの処理や表示を自動化する方法について解説します。
本題に入る前に、私たちが大きく依存している2つのnpmパッケージであるAxiosとZustandについて見ていきましょう。
🔗 Axios
私たちはAPI呼び出しにAxiosを使っています。Axiosを用いることで、API呼び出しをインターセプト(intercept: 傍受)し、必要に応じて変更できるようになります。
axios.interceptors.request.use(request => {
// リクエストをインターセプトする
});
axios.interceptors.response.use(request => {
// レスポンスをインターセプトする
});
この機能は、「認証トークンの設定」「リクエストやレスポンスでの大文字/小文字変換」「リクエストをサードパーティのドメインに送信するときに機密ヘッダーをクリーンアップする」といったタスクで有用です。また、API呼び出しのエラーを普遍的な形で処理するのにも向いています。
🔗 Zustand
Zustandは、小規模・高速・スケーラブルな最小限のステート管理ソリューションであり、flux原則1をシンプルな形で利用しています。私たちは、ステート管理にRedux/Reactの代わりにこのZustandを使っています。Zustandでストアを作成する簡単な例を以下に示します。
import { create } from "zustand";
const useBearStore = create(set => ({
bears: 0,
increasePopulation: () => set(state => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 }),
}));
Zustandについて詳しくは、ドキュメントをご覧ください。
🔗 エラーを検出する
それではアプリケーションでエラーを処理する方法を見てみましょう。最初のステップとして、私たちが任意のエラーを検出するために作成したAxiosのユニバーサルなレスポンスインターセプタを調べてみましょう。
import axios from "axios";
axios.interceptors.response.use(null, error => {
if (error.response?.status === 401) {
resetAuthTokens();
window.location.href = `/login?redirect_uri=${encodeURIComponent(
window.location.href
)}`;
} else {
const fullUrl = error.request?.responseURL || error.config.url;
const status = error.response?.status;
//TODO: エラー発生をユーザーに通知する
}
return Promise.reject(error);
});
個別のレスポンスでHTTP 401エラーが検出されると、このコードは認証トークンをリセットしてからユーザーをログインページにリダイレクトします。それ以外のエラーについては、ユーザーに適切なメッセージを表示する必要があります。
🔗 エラーを保存する
ユーザーは、エラーが発生したときにどのページを開いていてもおかしくないので、ユーザーが表示しているページの種類にかかわらずエラーメッセージを表示可能でなければなりません。そのためには、どのページからでも取り出し可能な形でエラーを保存しておく必要があります。
ここでZustandの出番がやってきます。Zustandでストアを作成するのは非常に簡単で、作成したストアはReactアプリケーションのフックとして利用できます。
import { prop } from "ramda";
import { create } from "zustand";
const useDisplayErrorPage = () => useErrorDisplayStore(prop("showErrorPage"));
export const useErrorDisplayStore = create(() => ({
showErrorPage: false,
statusCode: 404,
failedApiUrl: "",
}));
export default useDisplayErrorPage;
上のコードスニペットは、エラー関連のデータを保存するZustandストアを作成します。さらに、エラーがアプリケーション内のどこかで発生したかどうかを手軽にチェックできるフックも提供しています。
Axiosインターセプタに話を戻すと、useDisplayErrorPage
フックで以下のようにエラーを保存できます。
import axios from "axios";
import { useErrorDisplayStore } from "./useDisplayErrorPage";
axios.interceptors.response.use(null, error => {
if (error.response?.status === 401) {
resetAuthTokens();
window.location.href = `/login?redirect_uri=${encodeURIComponent(
window.location.href
)}`;
} else {
const fullUrl = error.request?.responseURL || error.config.url;
const status = error.response?.status;
useErrorDisplayStore.setState({
showErrorPage: true,
statusCode: status,
failedApiUrl: fullUrl,
});
}
return Promise.reject(error);
});
🔗 エラーを表示する
これで、私たちのReactアプリケーションのルートでuseDisplayErrorPage
を利用可能になりました。エラーが発生するとshowErrorPage
がtrue
になるので、これを用いてエラーページを表示できます。
import useDisplayErrorPage from "./useDisplayErrorPage";
import ErrorPage from "./ErrorPage";
const Main = () => {
const showErrorPage = useDisplayErrorPage();
if (showErrorPage) {
return <ErrorPage />;
}
return <>Our App</>;
};
次はErrorPage
コンポーネントを見てみましょう。このコンポーネントは、Zustandストアからエラーデータを読み取って、適切なエラーメッセージと画像を表示します。
import { useErrorDisplayStore } from "./useDisplayErrorPage";
import { shallow } from "zustand/shallow";
const ERRORS = {
404: {
imageSrc: "not-found.png",
errorMsg: "The page you're looking for can't be found.",
title: "Page not found",
},
403: {
imageSrc: "unauthorized.png",
errorMsg: "You don't have permission to access this page.",
title: "Unauthorized",
},
500: {
imageSrc: "server-error.png",
errorMsg:
"The server encountered an error and could not complete your request.",
title: "Internal server error",
},
};
const ErrorPage = ({ statusCode }) => {
const { storeStatusCode, showErrorPage } = useErrorDisplayStore(
pick(["statusCode", "showErrorPage"]),
shallow
);
const status = statusCode || storeStatusCode;
const { imageSrc, errorMsg, title } = ERRORS[status] || ERRORS[404];
return (
<div className="flex flex-col items-center justify-center h-screen">
<title>{title}</title>
<img src={imageSrc} className="mb-4" alt="Error Image" />
<div className="text-lg font-medium">{errorMsg}</div>
</div>
);
};
🔗 Railsのカスタムエラーページと連携する
Ruby on Railsには、「404エラー」「500エラー」「422エラー」など、よく発生するリクエストエラーページがデフォルトで付属しています。個別のリクエストエラーは、public/
ディレクトリ内の静的HTMLページに関連付けられています。Railsに備わっている静的HTMLページをカスタマイズして私たちのErrorPage
コンポーネントと同じようなものにすることも一応可能ではありますが、この方法ではさまざまな場所でのエラーページをメンテナンスし続けるのが難しく、長期的には同期できなくなってしまう可能性があります。そういうわけで、これらのシナリオについても私たちのErrorPage
コンポーネントを引き続き使うことに決めました。
これを実現するために、まずErrorsController
という名前のコントローラを作成しました。このコントローラに含まれているshow
アクションは、発生した例外からエラーコードを抽出して適切なビューをレンダリングします。
class ErrorsController < ApplicationController
before_action :set_default_format
def show
@status_code = params[:status_code] || "404"
error = @status_code == "404" ? "Not Found" : "Something went wrong!"
if params[:url]
Rails.logger.warn "ActionController::RoutingError (No route matches [#{request.method}] /#{params[:url]})"
end
respond_to do |format|
format.json { render json: { error: }, status: @status_code }
format.any { render status: @status_code }
end
end
private
def set_default_format
request.format = :html unless request.format == :json
end
end
次に、このshow
アクションに対応するビューファイルを作成します。このビューファイルは、Error
コンポーネントをレンダリングして、error_status_code
をプロパティとして渡します。
<%= react_component("Error", { error_status_code: @status_code }, { class: "root-container" }) %>
このError
コンポーネントは、上で作成したのと同じErrorPage
コンポーネントをレンダリングして、受け取ったエラーステータスコードを渡します。
import React from "react";
import ErrorPage from "./ErrorPage";
const reactProps = JSON.parse(
document.getElementsByClassName("root-container")[0]?.dataset?.reactProps ||
"{}"
);
const Error = () => <ErrorPage status={reactProps.error_status_code} />;
export default Error;
次に、config/routes.rbファイルに以下のルーティングを追加して、404リクエストや500リクエストがErrorsController
のshow
アクションを指すようにします。
Rails.application.routes.draw do
match "/:status_code", constraints: { status_code: /404|500/, format: :html }, to: "errors#show", via: :all
end
最後に、public/ディレクトリにあるHTMLテンプレートではなく、新しいコントローラとルーティングを利用するようRailsアプリケーションに指示する必要があります。これを行うには、config/application.rbファイルのclass Application < Rails::Application
ブロックに以下のコードを追加します。
config.exceptions_app = self.routes
🔗 マッチしないルーティングを処理する
ボットやクローラーから、存在しないパスへのアクセスを求めるリクエストがランダムにやってくることが時々あります。この場合アプリケーションはActionController::RoutingError
例外を発生します。この例外は、ユーザーがURLを入力ミスしたときにも発生しますが、その場合は単に例外を発生させるだけではなく、404ページを表示して、ユーザーがリクエストしたページが存在しないことをユーザーに通知する必要があります。
これを適切に処理するため、その名の通りのcatch_all
ルーティングを実装しました。これは、定義済みのルーティングに一致しないすべてのルーティングをキャッチするためのもので、ルーティング設定の末尾に配置します。末尾に配置することで、リクエストが他の定義済みルーティングと一致しない場合にのみ呼び出されるようになります。
Rails.application.routes.draw do
# (ここに他のルーティングが定義されている)
# エラー処理用の「すべてをキャッチする」ルーティング
match ":url", to: "errors#show", via: :all, url: /.*/, constraints: -> (request) do
request.path.exclude?("/rails/")
end
end
このルーティングでは、マッチしないリクエストの処理でも上述と同じErrorsController
を用いて404ページを表示します。
概要
元サイトの許諾を得て翻訳・公開いたします。