Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: APIレスポンスに応じたエラーページ表示を自動化する(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

Rails: APIレスポンスに応じたエラーページ表示を自動化する(翻訳)

エラー処理は、ソフトウェアを構築するうえで重要な側面のひとつです。弊社では、エラーページの表示を可能な限り自動化して、対処方法を統一できるようにしています。この作業を自動化することで、弊社のプロダクトエンジニアは本来の業務である高品質なソフトウェアのリリースに専念できるようになりました。

本記事では、私たちのneetoアプリケーションで用いているエラーページの処理や表示を自動化する方法について解説します。

本題に入る前に、私たちが大きく依存している2つのnpmパッケージであるAxiosZustandについて見ていきましょう。

🔗 Axios

axios/axios - GitHub

私たちはAPI呼び出しにAxiosを使っています。Axiosを用いることで、API呼び出しをインターセプト(intercept: 傍受)し、必要に応じて変更できるようになります。

axios.interceptors.request.use(request => {
  // リクエストをインターセプトする
});
axios.interceptors.response.use(request => {
  // レスポンスをインターセプトする
});

この機能は、「認証トークンの設定」「リクエストやレスポンスでの大文字/小文字変換」「リクエストをサードパーティのドメインに送信するときに機密ヘッダーをクリーンアップする」といったタスクで有用です。また、API呼び出しのエラーを普遍的な形で処理するのにも向いています。

🔗 Zustand

pmndrs/zustand - GitHub

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を利用可能になりました。エラーが発生するとshowErrorPagetrueになるので、これを用いてエラーページを表示できます。

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リクエストがErrorsControllershowアクションを指すようにします。

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ページを表示します。

関連記事

Rails 8: 組み込みのレート制限APIを導入(翻訳)

Rails: SidekiqからSolid Queueに移行したときの方法と注意点(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。